· Charlie Holland · DevOps  · 12 min read

Azure DevOps Pipelines: The CI/CD Tool Everyone Uses and Nobody Loves

Azure DevOps Pipelines is the CI/CD system enterprise teams end up with because they're an Azure shop. Not because they evaluated the options. Not because they love it. Because it was already there.

Azure DevOps Pipelines is the CI/CD system enterprise teams end up with because they're an Azure shop. Not because they evaluated the options. Not because they love it. Because it was already there.

I have built Azure DevOps pipelines at a Big Four consultancy, a global pharma company, a heavy equipment manufacturer, and a marketing agency. Four very different organisations. Four very different tech stacks. One thing in common: nobody chose Azure DevOps Pipelines. They inherited it.

The conversation always goes the same way. “We’re an Azure shop. We have Azure DevOps. We use the pipelines. They work. Mostly.”

“Mostly” is doing a lot of heavy lifting in that sentence.

Azure DevOps Pipelines is the CI/CD tool that works well enough that nobody can justify the cost of migrating away from it, but not well enough that anyone is enthusiastic about it. It is the beige carpet of build systems. It’ll do. It’s fine. Can we talk about something else?

No. We’re going to talk about it. Because I’ve spent the last five years living with it, and I have Opinions.

YAML pipelines vs Classic editor

The old Azure DevOps pipelines — now called “Classic” — were visual. You dragged tasks onto a canvas, configured them in dialog boxes, and the pipeline definition lived in Azure DevOps’s internal storage. Non-developers could build them. Project managers could understand them. They were accessible.

They were also unmaintainable. No version control. No diff. No pull request review. No way to copy a pipeline between projects without recreating it by hand. No way to template common patterns. No way to test locally. Every “Classic” pipeline I’ve encountered in the wild has been a snowflake, built by someone who’s since left, understood by no one, and changed only with prayers.

The migration to YAML pipelines was necessary. Microsoft started pushing YAML pipelines in 2019 and it’s clearly the future of the product. Pipeline-as-code. Version controlled. Reviewable. Templatable. All the right words.

The execution is… fine.

Here’s a minimal YAML pipeline:

trigger:
  branches:
    include:
      - main

pool:
  vmImage: 'ubuntu-latest'

steps:
  - task: UseDotNet@2
    inputs:
      packageType: 'sdk'
      version: '6.x'

  - script: dotnet build --configuration Release
    displayName: 'Build'

  - script: dotnet test --configuration Release --no-build
    displayName: 'Test'

That’s clear enough. The problems start when you need to do anything real.

Templates: powerful and impenetrable

Azure DevOps pipeline templates are genuinely powerful. You can define reusable step templates, job templates, stage templates, and even full pipeline templates with the extends keyword. You can pass parameters, use conditional insertion (${{ if }} expressions), iterate over arrays, and compose complex pipelines from modular components.

I have built template libraries that allow teams to deploy to multiple environments across multiple Azure subscriptions with a single pipeline file that’s twenty lines long. The template does all the heavy lifting. It’s elegant when it works.

When it doesn’t work, you’re in hell.

The problem is debugging. There is no template linter. There is no dry-run mode. There is no “show me what this YAML expands to after template resolution.” The only way to debug a template is to push it, wait for the pipeline to run (or fail to parse), and read the error message.

The error messages are often useless:

/azure-pipelines.yml (Line: 15, Col: 5): A template expression is not allowed in this context

Line 15 of which file? The root pipeline? The template? The template that the template includes? What context? What expression? Good luck.

The debugging technique, learned through bitter experience, is the pipeline equivalent of printf debugging:

steps:
  - script: echo "Parameter value is ${{ parameters.environment }}"
    displayName: 'Debug - check parameter'
  - script: echo "Condition result is ${{ eq(parameters.environment, 'production') }}"
    displayName: 'Debug - check condition'

Push. Wait. Read the logs. Adjust. Push. Wait. Read. Adjust. A feedback loop measured in minutes, for what should be a compile-time check.

GitHub Actions has the same fundamental problem (no local execution, no template expansion preview), but at least act exists as a community tool for local testing. Azure DevOps has no equivalent.

The extends keyword

The extends keyword deserves special mention because it’s both the best and worst feature of Azure DevOps templates.

# team-pipeline.yml
extends:
  template: platform-template.yml@templates-repo
  parameters:
    service: my-api
    environments:
      - dev
      - staging
      - production

This allows platform teams to define a standard pipeline structure (build, test, security scan, deploy to dev, integration test, deploy to staging, approval gate, deploy to production) and enforce it across all teams. Individual teams can’t skip the security scan or bypass the approval gate because the template controls the structure.

This is genuinely useful in enterprise environments where compliance requires standardised deployment processes. I’ve implemented this pattern at three different organisations and it works well.

The cost is complexity. The platform template becomes critical infrastructure. A bug in the template breaks every pipeline that extends it. Testing template changes requires a branch of the template repository, which means a separate resource reference in every pipeline that wants to test the new version. There’s no “template staging environment.”

Environments and approvals: the split brain

Here’s one of Azure DevOps’s most frustrating design decisions.

Your pipeline is defined in YAML. It’s in source control. It’s reviewed via pull request. It’s versioned. Great.

Your deployment environments — including approval gates, check configurations, and deployment policies — are configured in the Azure DevOps UI. They are not in source control. They are not versioned. They cannot be reviewed via pull request.

This creates a split brain. The pipeline definition says “deploy to production.” The environment configuration in the UI says “require approval from these three people before deploying to production.” If someone changes the approval list, there’s no audit trail in source control. If someone removes the approval gate entirely, the pipeline YAML looks the same.

You can automate environment configuration via the Azure DevOps REST API, and I’ve written scripts to do exactly that. But it’s not first-class. It’s a workaround for a design decision that treats deployment policy as a UI concern rather than a code concern.

Compare this to GitHub Actions, where environment protection rules are configured per-repository and at least visible alongside the workflow files. Or to Argo CD, where the entire deployment policy is GitOps-native. Azure DevOps is caught between two worlds.

Service connections

Service connections are Azure DevOps’s mechanism for connecting pipelines to external services: Azure subscriptions, AWS accounts, Docker registries, Kubernetes clusters, NuGet feeds, and dozens more.

They work. They are also a management nightmare at scale.

Each service connection has:

  • A type (Azure Resource Manager, Docker Registry, Kubernetes, etc.)
  • Credentials (service principal, managed identity, or federated)
  • A scope (which pipelines can use it, which environments it’s associated with)
  • A name (which is how pipelines reference it, and which can’t be easily changed)

At the heavy equipment manufacturer, we had over 40 service connections: multiple Azure subscriptions across development, staging, and production, plus container registries, Kubernetes clusters, and external SaaS integrations. Managing them was a part-time job:

  • Rotating credentials before they expire (service principal secrets have a maximum lifetime)
  • Updating connections when subscriptions are reorganised
  • Debugging “access denied” errors that could be IAM permission, could be expired credentials, could be the wrong service connection, could be the pipeline using a cached version of the connection
  • Onboarding new teams who need access to specific connections

Workload Identity Federation (federated credentials for service connections) has improved the credential management story significantly — no more secret rotation. But the management overhead of dozens of connections across dozens of pipelines remains. There’s no Infrastructure-as-Code story for service connections. It’s UI or API scripts.

Variable groups and Key Vault integration

Pipelines need secrets. Database passwords, API keys, connection strings. Azure DevOps provides two mechanisms: pipeline variables (inline in the YAML or in the UI) and variable groups.

Variable groups can be linked to an Azure Key Vault, which is the right approach. Secrets live in Key Vault, variable groups reference them, pipelines reference variable groups. Separation of concerns. Centralised secret management. Audit trail.

The setup is fiddly:

  1. Create a service connection to the Azure subscription containing the Key Vault
  2. Grant the service connection’s service principal GET permission on Key Vault secrets
  3. Create a variable group linked to the Key Vault
  4. Select which secrets to expose (you can’t expose all secrets — you must select them individually)
  5. Reference the variable group in your pipeline YAML
  6. Use the variables with the $(secret-name) syntax

Step 4 is the one that catches people. If someone adds a new secret to Key Vault, it doesn’t automatically appear in the variable group. Someone must go to the UI, edit the variable group, and add the new secret. This is manual, error-prone, and easy to forget.

Step 2 is the one that breaks things. Key Vault access policies (or RBAC, depending on the permission model) need to grant access to the service principal behind the service connection. If the service connection’s credentials rotate, the Key Vault policy might reference a stale principal. If the Key Vault uses RBAC instead of access policies, the role assignment is different. I’ve debugged this exact issue at three different clients.

Agent pools: the hidden operations burden

Microsoft provides hosted agents — VMs that run your pipeline steps. They work for simple cases. For enterprise teams, they rarely suffice.

Hosted agents have:

  • No persistent state between runs (by design, but slow for large dependency trees)
  • No network access to internal resources (databases, on-prem services, private endpoints)
  • Limited installed tooling (you can install what you need, but it adds build time)
  • Usage limits on parallel jobs (you need to buy additional parallel jobs for concurrent builds)

So you run self-hosted agents. VMs or containers that you manage, running the Azure Pipelines agent software, registered to your organisation’s agent pool.

Running self-hosted agents is a job. Not a task, not a checkbox — a job. Someone needs to:

  • Provision the agent VMs (or containers)
  • Install the right tooling (SDKs, runtimes, CLI tools)
  • Keep the tooling updated (new .NET SDK version? new Node version? update every agent)
  • Monitor agent health (agents go offline, agents run out of disk space, agents get into weird states)
  • Scale the pool (too few agents = queued builds, too many = wasted infrastructure cost)
  • Manage agent security (these VMs have network access to your production infrastructure — treat them accordingly)
  • Handle agent updates (Microsoft releases agent updates regularly, and some pipeline features require minimum agent versions)

At the pharma company, we ran a pool of 12 self-hosted agents on Azure VMSS (Virtual Machine Scale Sets) with auto-scaling. The scale set configuration, the custom image pipeline (build a VM image with all tooling pre-installed), the monitoring, the troubleshooting — it was effectively a platform within the platform. We had a wiki page called “Agent Troubleshooting” that was four pages long and still didn’t cover every scenario.

The GitHub Actions elephant

Let’s address the obvious.

Microsoft acquired GitHub in 2018. GitHub Actions launched in 2019. By mid-2022, it’s clear where Microsoft’s investment is going.

GitHub Actions has:

  • A massive marketplace of community actions
  • Better YAML syntax (opinionated: the uses keyword is more intuitive than Azure DevOps’s task references)
  • Native integration with GitHub’s pull request workflow
  • act for local testing (community-maintained, imperfect, but it exists)
  • Rapidly improving feature set (reusable workflows, deployment environments, OIDC for cloud authentication)

Azure DevOps has:

  • Deeper Azure integration (native support for Azure service connections, Azure Resource Manager deployment tasks)
  • Better enterprise features (agent pools, variable groups, release gates, audit logs)
  • Established organisational investment (hundreds of existing pipelines, configured service connections, trained teams)

The feature gap between GitHub Actions and Azure DevOps Pipelines is narrowing. For new projects, starting on GitHub Actions is the obvious choice. For existing Azure DevOps organisations with mature pipeline estates, migrating is a multi-month project that most teams can’t justify.

Migrating means:

  • Rewriting every pipeline YAML file (the syntax is different)
  • Recreating every service connection (GitHub calls them “secrets” and “environments”)
  • Migrating or recreating variable groups
  • Rebuilding or replacing self-hosted agents (GitHub calls them “self-hosted runners”)
  • Retraining teams
  • Running both systems in parallel during the migration

At the marketing agency, we estimated the migration from Azure DevOps to GitHub Actions at three months of dedicated effort for approximately 30 pipelines. The business said no. Three months of engineering time for a CI/CD system that “works fine” is a hard sell, even when you explain that the current system is slowly becoming a dead end.

Most enterprise teams will be on Azure DevOps for years yet. Microsoft knows this, and they’ll maintain it — they’re not going to EOL a product that millions of enterprise developers depend on. But the innovation is happening in GitHub Actions. New features appear in Actions first (or only). The community energy is in Actions. The blog posts, the tutorials, the conference talks — all Actions.

The verdict

Azure DevOps Pipelines is adequate. I mean that precisely. It meets the requirements. It builds code. It runs tests. It deploys applications. It integrates with Azure. It handles enterprise concerns (approvals, audit, compliance). It does the job.

It’s not elegant. The YAML syntax is verbose and fiddly. The template system is powerful but opaque. The split between YAML-defined pipelines and UI-defined deployment policy is a design flaw. The debugging experience is poor. The documentation is comprehensive but scattered across Microsoft Learn in a way that makes it hard to find definitive answers.

It’s not where Microsoft’s heart is. The investment, the innovation, the excitement — it’s all in GitHub Actions. Azure DevOps Pipelines is in maintenance mode in spirit, if not officially. Features still ship, bugs still get fixed, but the pace is glacial compared to Actions.

But it works. It’s enterprise-ready. It’s already there. And in enterprise technology, “already there” wins more often than “technically superior.” I’ve seen organisations choose worse tools for worse reasons.

If you’re starting fresh: use GitHub Actions. No question.

If you’re on Azure DevOps with a mature pipeline estate: stay. Invest in templates, standardise your practices, automate your service connection management, and keep an eye on GitHub Actions for the eventual migration. That migration will come, but it doesn’t need to be today.

And if someone suggests migrating to Jenkins, leave the room.

Back to Blog

Related Posts

View All Posts »
Shift-Left Security Sounds Great Until You See the Pipeline

Shift-Left Security Sounds Great Until You See the Pipeline

Everyone preaches 'find vulnerabilities earlier.' The concept is sound. The execution is usually a disaster — every build fails on day one, exception lists grow longer than vulnerability lists, and security becomes a rubber stamp that makes everyone feel safe without actually being safe.

Terraform Won. Get Over It.

Terraform Won. Get Over It.

The Infrastructure as Code wars are over. Terraform won. And yet enterprise teams are still clinging to vendor-native tooling like ARM templates and CloudFormation because 'it's supported by the vendor.' Supported doesn't mean good.

Iteration and Agility

Iteration and Agility

Since the 1950s we've been looking for better ways to build software. Iterative development, continuous integration, and SaaS delivery have fundamentally changed the feedback loop — and the winners are those who embrace it.

The DevOps Bubble Is Bursting — Good

The DevOps Bubble Is Bursting — Good

We never really needed ten thousand DevOps engineers. The market's not dying — it's clearing out the noise. When the dust settles, the builders will still be here.