The same eleven lines, copied into thirty-one files
A pipeline that works is a temptation. The first one takes a week to get right; the second is a copy of the first with two words changed; by the thirty-first, an organisation has thirty-one YAML files that all build, test, and deploy in almost exactly the same way — and the day someone decides every deploy must now run a security scan, that is thirty-one files to edit, thirty-one reviews to chase, and one file quietly missed that becomes the gap an auditor finds a year later. Copy-paste is not a time-saver here; it is a debt that compounds silently, paid in the one pipeline nobody remembered to change. This part is about not incurring it — about writing the shared thing once and pointing many pipelines at it.
Azure Pipelines gives you three tools for staying out of that trap, and they are the whole of this part. Variables pull the values that differ — a resource-group name, a build configuration, a secret — out of the pipeline body so the same steps run against different inputs. Parameters let a reusable pipeline be shaped by its caller before it runs, with types the system checks. And templates let steps, jobs, or whole pipeline shapes be written once and reused across many files, so the security scan is added in one place and lands in all of them. Keep the three straight and a pipeline estate stays DRY, configurable, and — as the templates section will argue — safe. Confuse them, and the most common way to do so is a trap hiding in a brace.
Variables, and the three clocks they run on
A variable is a name-value pair you define once and reference many times — a build configuration, a resource-group name, a flag that turns a step on or off. That much is ordinary. What trips up nearly everyone, on the exam and painfully on the job, is that Azure Pipelines reads variables at three different moments, and the moment is chosen by the punctuation you wrap the name in. Get the punctuation wrong and the pipeline does not error; it quietly reads the wrong thing — usually an empty string — and ships it. This is the single most consequential fact in this part, so it is worth slowing down for.
Macro syntax, $(var), is the everyday form. It is expanded at runtime, just before a step runs, and the agent substitutes the value in place. If the variable has no value, the agent leaves the text $(var) untouched rather than blanking it, on the reasoning that an empty substitution might silently break the script it lands in. Template expression syntax, ${{ variables.var }}, is different in kind: it resolves at compile time, before the run starts at all — so it can only see values that already exist when the YAML is assembled, and anything set later, during the run, reads as empty. Runtime expression syntax, $[ variables.var ], is evaluated as jobs are dispatched, which is why it is the form you use in conditions and in passing output variables between jobs. Three braces, three clocks.
Pick the wrong brace, ship an empty string.
| Syntax | When it resolves | What it is for | Failure symptom |
|---|---|---|---|
| Macro · $(var) | Runtime, just before a step executes | Feeding values into task inputs and scripts — the everyday case | Undefined leaves the literal $(var) in the output |
| Template expression · ${{ variables.var }} | Compile time, before the run begins | Reusing YAML, shaping the pipeline before it runs; works as a key or a value | Anything set during the run reads as an empty string |
| Runtime expression · $[ variables.var ] | Runtime, as jobs are dispatched | Conditions, and passing output variables between jobs and stages | Undefined silently becomes an empty string |
The rule that saves you is to ask, of every variable, does the value exist yet when this line is read? A value another step produces during the run does not exist at compile time, so reaching for it with ${{ }} is asking a question before the answer was written — and the answer comes back blank. That one confusion is the spot-the-error drill at the end of this part, because it is the mistake you will actually make.
Secrets, and the group that never holds them
Some values must not sit in the YAML at all. A database password committed to a repository is a password published to everyone who can read the repository — and in a pipeline that means every contributor, every fork, and the whole history that git never forgets. Azure Pipelines lets you mark a variable as secret, which keeps its value out of logs and off the definition, and it masks the value in output. But a secret typed into a pipeline is still a secret you are managing by hand, per pipeline, and that does not scale past a few.
The answer to values shared across many pipelines is a variable group, which lives in the project's Library rather than in any one pipeline. Define the group once — the connection strings, the region, the non-secret configuration a family of pipelines shares — and each pipeline references it. Change a value in the Library and every pipeline that references the group gets the new value, with no file edited. And the group can do the one thing that matters most for secrets: it can link to Azure Key Vault, so that the secret values are never stored in Azure DevOps at all. The group holds only the names of the secrets; the pipeline fetches the current values from Key Vault at run time, under an access policy you control, and the secret lives its whole life in the vault built to hold it.1
- Variable group
- A named set of variables stored in the project's Library and shared across pipelines, so a shared value is defined and changed in one place. A group can be linked to an Azure Key Vault, in which case it stores only the secret names and the pipeline reads the values from the vault at run time — the secret never rests inside Azure DevOps.
That link is the shape you want to reach for by reflex. It puts secrets where audit, rotation, and access control already live, and it means a person with permission to edit a pipeline is not thereby a person who can read production's database password. The secret and the pipeline that uses it are kept deliberately apart — the same instinct, in miniature, that keeps a backup off the disk it protects.
Play it through
Three minutes. Watch a database password print straight into the run log, then move it into a variable group linked to Key Vault, so the value leaves Azure DevOps and the log has nothing worth reading. It plays on its own and stops when it needs your hands.
Parameters — shaping a pipeline before it runs
Variables carry values through a pipeline while it runs. Parameters do something earlier and different: they let the caller of a reusable pipeline decide its shape before it runs at all. A parameter is declared with a type — string, number, boolean, object, or the special step, job, and stage list types — and it is resolved at compile time, alongside the ${{ }} template expressions of §2. Because the type is checked when the YAML is assembled, a caller who passes the string "maybe" where a boolean was declared is stopped before a single agent starts, not three minutes into a deploy.
The distinction from a variable is worth stating cleanly, because it is a common interview question. A parameter is compile-time and typed, and it can change what steps exist — you can wrap a whole test stage in ${{ if parameters.runTests }} so that, depending on the value passed, the stage is present in the pipeline or is never generated. A variable is a value the running pipeline reads; it cannot add or remove steps, because by the time a variable is read at runtime the set of steps has already been decided. Put plainly: parameters decide what the pipeline is; variables decide what it works with. That is exactly why templates, next, take parameters and not merely variables — a template's whole job is to let its caller shape it.
Templates — and the difference between include and extends
A template is a piece of pipeline — a set of steps, a job, a stage, or a whole pipeline shape — written in its own file and reused by others. It is the direct answer to the thirty-one-copies problem: write the build-and-deploy sequence once, add the security scan to it once, and every pipeline that uses the template gets the scan. There are two ways a pipeline pulls a template in, and the difference between them is not cosmetic — it is the difference between a convenience and a control.
With include (referencing a template as a step, job, or stage), the template's content is inserted at the point you name, and your pipeline stays in charge of everything around it. Handy, DRY, and entirely voluntary — a pipeline can include a template and then do whatever else it likes. With extends, the relationship inverts: the template owns the whole pipeline shape, and your file may only supply the parameters the template allows. It cannot add a rogue stage, skip the scan, or slip in a step the template did not sanction. That inversion is why extends is a security boundary, not merely reuse: an organisation can require, through a check on a protected resource, that every pipeline touching production extends a specific approved template — and thereby guarantee that every deploy, no matter who wrote the pipeline, passes the gates the template encodes.2
- Step / job / stage template
- A fragment reused by include. The calling pipeline stays in control and can do anything around the fragment.
- extends template
- Owns the entire pipeline shape. The caller supplies only allowed parameters and can add nothing the template did not permit — which is what makes it enforceable.
- Required check
- A rule on an environment or service connection that a pipeline must extend a named template to use it. This is how an org turns a template into a gate no one can route around.
Here is a step template shaped by a parameter, and a pipeline that extends it — the caller changes one value and can change nothing else:
# build-deploy.yml — the approved template, written once parameters: - name: project type: string - name: runScan type: boolean default: true stages: - stage: Build jobs: - job: build steps: - script: dotnet build $(project) - ${{ if eq(parameters.runScan, true) }}: - script: ./security-scan.sh # the gate the org requires # azure-pipelines.yml — one of thirty-one, now four lines extends: template: build-deploy.yml parameters: project: warehouse.csproj
Every pipeline in the estate collapses to those last four lines, and the scan can never be dropped from any of them, because the pipeline is not permitted to remove it. One file to change, one gate no one can skip — DRY and safe in the same move.
Thirty-one pipelines become one template, and the warehouse config leaves the YAML
Basecamp Outfitters arrived in the estate with thirty-one pipelines that build and deploy in almost exactly the same way — which meant that when Campux's standard added a mandatory security scan, it was thirty-one edits and one near-certain miss. The reader stops the bleeding the way this part teaches: the common build-and-deploy sequence is lifted into a single shared template, and the thirty-one pipelines are rewritten to extends it, each collapsing to a handful of lines that pass only its own project name. The scan now lives in one file, and — because the pipelines extend rather than include — no team can quietly ship a pipeline that skips it.
The warehouse deploy has a second problem: its connection string and a storage key sit in plain text in the pipeline, copied there years ago. The reader moves them into a variable group in the Library, links that group to Azure Key Vault, and leaves only the secret names in Azure DevOps — the values now live in the vault, fetched at run time under an access policy. Editing the warehouse pipeline no longer means being able to read production's storage key. The estate is smaller, its one gate is unskippable, and its secrets have left the YAML for good — with the standing service-connection credential, the org's most dangerous one, left for Part I, where it is fixed last and best.
The change that landed in thirty-one pipelines at once
Security asks that every deploy run a new scan, starting this sprint. On an estate of copy-pasted pipelines that is a week of edits and a lingering fear you missed one. On yours, the scan is a step in one shared template that every pipeline extends — you add it once, open one pull request, and it is live everywhere by lunch, with no pipeline able to opt out. You did nothing clever on the day; the cleverness was months earlier, when you refused to paste the thirty-second copy and wrote the template instead. Most of the value in this work is bought quietly, before anyone is watching.
Examination
Four drills, then two situations. Write your answer before you reveal the reasoning, or the exercise is worthless. Nothing is stored.
B — only a compile-time value can change what the pipeline is. Whether a stage exists is decided when the YAML is assembled, before any agent starts; that is the compile-time moment, and ${{ }} is the only one of the three read there. A macro $(var) is substituted at runtime, far too late — by then the set of stages is already fixed, so it can feed a script but never add or remove a stage. C's runtime expression is later still, evaluated as jobs dispatch, for conditions and cross-job outputs. D is the whole misconception this part exists to kill: the punctuation is the clock, and choosing it wrongly is how a pipeline silently reads a value that does not exist yet. Reach for ${{ }} whenever the answer must be known before the run.
B — the group linked to Key Vault holds only the names; the values stay in the vault. A variable group lives in the Library, is shared across pipelines, and changes in one place; linking it to Key Vault means Azure DevOps stores just the secret names and each pipeline fetches the value at run time under an access policy. A gets masking but fails every other requirement: the password is stored six times, changed in six places, and sits inside Azure DevOps where a pipeline editor can reach it. C misuses parameters — a secret passed as a compile-time parameter is a secret written into whoever calls the template, which is worse, not better. D confuses a syntax for reading a variable with a place to store one; $[ ] is not a secret store. The instinct to keep: secrets belong in the vault built for them, referenced by name.
Extends owns the shape, a typed parameter decides it at compile time, and a group linked to Key Vault keeps secrets out of Azure DevOps. Those three are the load-bearing facts of this part: extends is what makes a template enforceable rather than optional, parameters shape the pipeline before it runs, and the Key Vault link is how secrets stay in the vault built for them. The two rejects are the confusions to burn out. A variable — in any syntax, $[ ] included — cannot add or remove steps, because by the time a variable is read the set of steps is already fixed; only compile-time parameters and template expressions shape structure. And ${{ }} resolves before the run, so a value a step sets during the run is invisible to it and reads as empty — the exact trap in the next drill.
# warehouse deploy — publish the build, tagged
steps:
- script: |
echo "##vso[task.setvariable variable=buildTag] \
$(git rev-parse --short HEAD)"
displayName: Compute the build tag during the run
- script: echo "Publishing build ${{ variables.buildTag }}"
displayName: Publish, tagged with the commit
Line reading ${{ variables.buildTag }} — the value is produced at runtime but read at compile time. buildTag is created during the run by task.setvariable; it has no compile-time value. Template expressions resolve before the run begins, so when the assembler reaches ${{ variables.buildTag }} the variable does not exist yet, and it silently coalesces to an empty string — no error, a blank tag, and a green pipeline that hides the bug. The fix is macro syntax, $(buildTag), which is substituted at runtime after the first step has set it. A is wrong — the command is correct. C is wrong — setting a variable from a script is exactly what the logging command does. D is wrong — within one job a later step reads a variable an earlier step set; the multi-job case is the one that needs $[ ] output mapping. The whole trap is the clock, not the plumbing.
Concede the deadline before you argue the design. Your teammate is not wrong that shipping matters, and treating the template as a purity crusade loses the room. Start there: the goal is a working pipeline today, and you can have one. The disagreement is only about which "today" action leaves next quarter better — and copy thirty-two does not, because it adds a thirty-second place to forget the next org-wide change, exactly the debt §1 opens on.
Price the copy honestly, both sides. The copy feels free now and bills later: the day a mandatory scan or a credential rotation arrives, it is thirty-two edits and one near-certain miss — and the miss is the pipeline an auditor finds. If a shared template already exists, pointing this pipeline at it with extends is fewer lines than the copy, not more, so "template later" is slower even today. If no template exists yet, the honest move is the small one: ship the copy now because the deadline is real, but write the ticket and the calendar time to factor it — and never let "copy N" become the argument for "copy N plus one."
Offer the ten-minute version, not the lecture. If a template is close, pair for ten minutes and convert this one; if it is not, agree the copy ships and the factoring is the next task, with a name on it. Either way you have kept the teammate on side and stopped the estate quietly gaining another file no future change can safely reach. The value in this work is bought before anyone is watching — one refused paste at a time.
Treat it as already disclosed, because it is. The value sat unmarked in a pipeline definition every contributor could read, in a repository history that keeps it forever, and in the output of every run since. Nobody has to have taken it for the honest answer to be that you cannot prove nobody did. So the first action is not a refactor. It is a rotation: change the password in the database, update whatever consumes it, and accept the short window of breakage while the old value is retired. Everything else you do is worthless while the old value still opens the door.
Then move the new one somewhere the same mistake is not available. Create a variable group in the project's Library, link it to an Azure Key Vault, and put the new password in the vault. Azure DevOps then holds only the secret's name, and each run fetches the current value from the vault under an access policy you control. Reference the group from the warehouse pipeline and authorize that pipeline to use it — the group's own permissions are a separate gate from the Key Vault link, and a run cannot reference a group it has not been authorized for. Editing the pipeline stops being the same thing as reading production's password.
Say the uncomfortable part in the review, without making it about the author. Two years and hundreds of runs means the rotation is the news, not the bad line. Write it plainly: the credential is being rotated, here is the window, here is where the value lives now, here is who can reach it. Then ask the question that stops the next one — where else in the estate does this pattern exist? One hardcoded password is rarely alone, and the search is cheaper today than the incident report is later.
Five things worth carrying out of this part
- Three braces, three clocks. ${{ }} resolves at compile time, before the run; $[ ] as each job is dispatched; $( ) just before a step runs. Ask of every line: does the value exist yet when this line is read?
- The failure is silent, which is why it bites. A macro with no value is left in the output as the literal $(var); a template or runtime expression with no value coalesces to an empty string. No error, a blank, a green run.
- A variable group in the project's Library shares values across pipelines and is changed in one place. Link it to Azure Key Vault and Azure DevOps stores only the secret names — the values stay in the vault and are fetched at run time.
- Parameters are typed and resolved at compile time, so they can decide what steps exist. Variables are read while the pipeline runs, so they can only decide what those steps work with.
- Including a template reuses a fragment and leaves the caller in charge. Extending one hands the template the whole pipeline shape — and a required-template check on a protected resource is what turns that from a habit into a gate no one can route around.
- Linking a variable group to Key Vault does not, by itself, decide who can read the secrets. Naming a variable group in a YAML file is not enough authorization on its own — because anyone who can push code to the repository could otherwise extract the secrets it holds, Azure Pipelines separately requires you to authorize which pipelines may use that group, in the group's own permissions, before a run can reference it. The Key Vault link controls where the value lives; that separate authorization controls who can make a pipeline read it. ↩
- An extends template is only a boundary where something enforces it. Naming extends in a pipeline file does not by itself stop a different pipeline from skipping it; the guarantee comes from a required template check configured on the protected resource the pipeline touches — a service connection, say — which fails the run if it does not extend the approved template. Skip configuring that check on the resource and a pipeline author is free to not extend anything at all. ↩