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.1
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.1
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.2
- 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.
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.3
- 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.
Name the shape before the steps, so they know you are not improvising. "It travels a pipeline, and the pipeline is always the same shape." Then walk it: the merge is the trigger; the build turns the source into an artifact once, so the exact thing tested is the exact thing shipped; the tests run and the pipeline refuses to proceed if they fail — that is the whole point, the machine says no so a tired human doesn't have to. The tested artifact is published, and deployed to staging automatically.
Then put the human gate where it belongs and mean it. "Production is a separate environment with an approval — a named person confirms, so no single merge silently reaches customers at 4pm on a Friday. The deploy itself is a strategy, not a leap: I'd roll it out to a slice first — a canary — watch the signals, and roll back in a command if they're bad. And nothing in that pipeline holds a stored secret; it authenticates to Azure with a federated identity scoped to just the resource group it deploys to." Five boxes, one gate, one safe rollback, one scoped credential.
Close on what the shape reveals about you. "The reason I answer it as a shape rather than a tool is that it's the same shape on Azure DevOps or GitHub Actions — I've built it on both. What's portable isn't the YAML dialect; it's the decisions: test before ship, a human before production, a credential that's scoped rather than trusted, a rollback that's one command." That reframes you from "person who knows one tool" to "engineer who owns delivery" — and it is simply true, which is why it survives the follow-up questions. That, one final time, is the method of this whole bootcamp: the gap was never the missing noun; it was the proof, and now you carry it.
Five things worth carrying out of this hub
- A pipeline replaces a fragile person with a legible machine: a defined sequence that runs the same way every time, records what it did, and refuses to skip the step that matters because it is late. That is the whole value — repeatability and a record, not speed.
- Every pipeline shares one shape: trigger, build, test, package as an artifact, deploy to an environment through an approval. The nine parts of this track are that shape, elaborated and taught in order. Draw it until it is reflex.
- CI is merging often with every change built and tested; CD is keeping that build always ready to release through a repeatable deploy. The pipeline is the machine that makes both cheap enough to actually do.
- The discipline is platform-independent. Learn it here on Azure DevOps and you can read GitHub Actions on the first morning, and vice versa — because the nouns differ but the shape is the field's.
- Meeting an inherited estate: new pipelines born as code, existing ones convert when touched, riskiest credentials convert first regardless. Neither crusade nor surrender — the migration posture of this whole bootcamp, one last time.
- Treat any specific market-share claim about Azure DevOps versus GitHub with suspicion — the numbers are argued about and change yearly; the direction is settled in both halves: an enormous installed base still ships on Azure DevOps, and net-new investment flows toward GitHub. Both halves matter to your career, which is precisely why this track teaches the platform-independent discipline first and the Azure DevOps nouns second. Read the current positioning before repeating anyone's prediction of the platform's demise, including one you infer from this page. ↩