← Back to blog

Azure DevOps Pipelines — the parts they don't document

Rico Twesten-Weber Principal DevOps Engineer
azure-devopsci-cddevopspipelines

Azure DevOps Pipelines documentation is thorough when you’re doing something simple. Need a basic build-and-deploy YAML pipeline? The docs walk you through it. Need a multi-stage pipeline with conditional execution, cross-stage variable passing, and templates pulled from an external repository? Good luck. You’re on your own, reading GitHub issues from 2021 and hoping the behavior hasn’t changed.

I’ve spent more time debugging Azure DevOps pipeline edge cases than I’d like to admit. Here’s what I’ve learned so you don’t have to repeat the same mistakes.

Why do conditional stages behave differently than you expect?

The condition: property on stages and jobs is one of those features that seems straightforward until it isn’t. The basic cases work fine: succeeded(), failed(), always(). The problems start when you combine conditions with dependsOn.

Here’s the thing the docs don’t spell out clearly. When a stage is skipped because its condition evaluated to false, any stage that depends on it is also skipped by default. This makes sense once you know it, but it creates a chain reaction that can skip half your pipeline if you’re not careful.

Say you have a deployment stage that only runs on the main branch. A notification stage depends on the deployment stage to report the result. On a feature branch, the deployment stage is skipped, and so is the notification stage. You expected the notification to always run, but succeeded() returns false for a skipped dependency.

The fix is to use explicit conditions on downstream stages instead of relying on the default behavior. Something like condition: not(canceled()) or a custom expression that checks whether specific previous stages succeeded, failed, or were skipped. The in() function with stageDependencies gives you that control, but the expression syntax is fragile and inconsistently documented.

Why doesn’t variable group scoping work the way you assume?

Variable groups in Azure DevOps are pipeline-scoped. Not stage-scoped. When you link a variable group to a pipeline, those variables are available everywhere. That sounds convenient until you need different values for the same variable in different stages.

The typical scenario: you have a deployment pipeline with dev, staging, and production stages. Each stage needs a different subscription ID, resource group, and set of configuration values. Your first instinct is to create three variable groups and link one to each stage.

You can’t. Variable groups are linked at the pipeline level. There’s no native way to say “use this variable group only in this stage.”

The workaround is variable templates. You define a template file for each environment that sets the variables you need, and you include the appropriate template in each stage. It works, but it means restructuring your pipeline around a limitation that feels like it should have been solved years ago.

Runtime parameters are another option. You can define parameters with default values and pass them to stages explicitly. This is cleaner than variable templates for some patterns but adds verbosity. Neither approach is well-documented for multi-stage scenarios.

How do service connections work with Workload Identity federation?

Setting up an ARM service connection with Workload Identity federation in Azure DevOps requires steps that aren’t in any single documentation page. The docs explain the concept of federated credentials. They explain how to create a service connection. They don’t explain the specific combination of settings that makes it work.

What I’ve found through trial and error: you need a managed identity with a federated credential whose subject matches the exact format Azure DevOps uses. That format is sc://<organization>/<project>/<service-connection-name>. Get one character wrong and the token exchange fails silently, giving you a generic “authorization failed” error that doesn’t point to the federated credential as the problem.

The managed identity needs the correct RBAC role assignments on the target resources before you create the service connection. If you create the connection first and add roles later, you sometimes need to delete and recreate the connection for it to pick up the new permissions. There’s no “refresh” button. The Azure DevOps UI caches the validation result, and I’ve spent time debugging permissions that were actually correct but that the UI refused to acknowledge.

Why do template repositories fail silently?

Using pipeline templates from another repository is powerful and poorly documented. The resources.repositories block lets you reference an external repo, and you can call templates from it using template: filename@repoAlias.

Here’s what catches people. If the template reference is wrong, a file path is incorrect, or a parameter name doesn’t match, Azure DevOps sometimes fails silently. The template gets ignored or replaced with an empty block. No error. No warning. Your pipeline runs with missing stages and you don’t notice until someone asks why the security scan didn’t execute.

Parameter passing between the calling pipeline and the template has its own quirks. Parameters defined in the template with default values work fine. Required parameters without defaults throw errors, as expected. But parameters with complex types like object or step lists can silently lose data if the YAML structure doesn’t match exactly what the template expects. No schema validation. No error message. Just missing steps.

The only reliable debugging approach I’ve found is to use the “Preview” feature on the pipeline run page, which shows the fully expanded YAML after template resolution. If a stage or step is missing in the preview, the template reference is broken.

How do you pass output variables between stages?

Passing output variables from one stage to another in Azure DevOps requires a specific expression syntax that I’ve never seen anyone get right on the first try.

Within a job, output variables use the task-name.variable-name format. Between jobs in the same stage, you use dependencies.job-name.outputs['task-name.variable-name']. Between stages, you use stageDependencies.stage-name.job-name.outputs['task-name.variable-name'].

The bracket-and-quote syntax matters. Single quotes inside square brackets. If you use double quotes or skip the brackets, it evaluates to empty string instead of throwing an error. You’ll spend an hour wondering why your variable is blank before you realize you used the wrong quote character.

Mapping these output variables into stage-level variables requires a variables block with explicit expressions, and those variables are only available in the stage where they’re mapped. You can’t reference stageDependencies directly in a task input. You have to map it to a variable first, then reference the variable. It’s two indirections where one should suffice.

Why is Azure DevOps documentation incomplete for advanced use?

None of these issues are bugs. Azure DevOps Pipelines is a capable system once you learn its quirks and limitations. The problem is that the official documentation covers the happy path and leaves the edge cases to community blog posts, Stack Overflow answers, and GitHub issues.

Microsoft’s docs team writes for the 80% case. The other 20%, where you’re combining features in ways that are common in real-world pipelines, lives in tribal knowledge. The community fills the gaps, but it takes time to find the right answer among outdated posts and conflicting advice.

If you’re starting a new Azure DevOps pipeline, keep it simple. Use templates early. Test the conditional logic before you build ten stages on top of it. And when something fails silently, check the YAML preview first. It’ll save you more debugging time than any other single trick.

Rico Twesten-Weber

Principal DevOps Engineer. I build platforms that run themselves, and write about DevOps and AI.

Explore

Connect

© 2026 Rico Twesten-Weber Impressum Datenschutz