Guide - Azure DevOps pipelines for a .NET application

Your application goes to production by hand. Someone publishes from their machine, copies the output, and watches. It works until that person is away, or until a release has to come back out.

Microsoft’s documentation covers every task and its inputs. This is the shape of the pipeline instead, and the four places a first one usually goes wrong.

Guide · for a developer or lead

Azure DevOps Pipelines for a .NET Application

Will Pickeral, William Belle LLC · support@williambelle.co

You have a .NET application that goes to production by hand. Someone runs a publish from their machine, copies the output somewhere, and watches. It works until the person who knows the steps is on vacation, or until a release has to come back out and nobody knows how.

This is how to replace that with an Azure DevOps pipeline, and which decisions in it are the ones that matter. Microsoft's documentation covers every task and its inputs. What follows is the shape, and the four places a first pipeline usually goes wrong.


Build once, deploy many

The one structural decision. Everything else follows from it.

A pipeline that builds separately for each environment ships a different set of bytes to test than it ships to production. You then find out whether the production build works by running it in production. That is the failure the whole exercise is meant to remove.

So the build stage produces one artifact, and every later stage deploys that same artifact. Nothing after the build compiles anything.

The practical consequence: your build cannot bake in per-environment settings. A connection string that differs between test and production cannot be in the published output, because there is only one published output. It has to be supplied at deploy time or read at run time.

trigger:
  branches:
    include: [ main ]

pool:
  vmImage: ubuntu-latest

stages:
- stage: Build
  jobs:
  - job: Build
    steps:
    - task: UseDotNet@2
      inputs:
        packageType: sdk
        useGlobalJson: true

    - task: DotNetCoreCLI@2
      displayName: Restore
      inputs:
        command: restore
        projects: '**/*.csproj'

    - task: DotNetCoreCLI@2
      displayName: Test
      inputs:
        command: test
        projects: '**/*Tests.csproj'
        arguments: '--configuration Release --collect "Code coverage"'

    - task: DotNetCoreCLI@2
      displayName: Publish
      inputs:
        command: publish
        publishWebProjects: true
        arguments: '--configuration Release --output $(Build.ArtifactStagingDirectory)'
        zipAfterPublish: true

    - task: PublishPipelineArtifact@1
      inputs:
        targetPath: $(Build.ArtifactStagingDirectory)
        artifactName: app

useGlobalJson: true reads the SDK version from your global.json instead of taking whatever the hosted agent has installed today. Without it, an agent image update changes your compiler without anyone deciding to.


Give the pipeline an identity, not a secret

A service connection is how the pipeline proves to Azure that it may deploy. There are two kinds, and the default in older documentation is the wrong one.

Use workload identity federation. Azure DevOps presents a short-lived token that Entra ID trades for access. No secret is created, so no secret expires at 2am on a Sunday, and there is nothing in the project that could be copied out of it.

The alternative is a service principal with a client secret. It works, and then it expires — usually during a release, usually to the surprise of everyone. If you have one already, converting it is a change to the service connection, not to the pipeline.

Scope the connection to a resource group rather than a subscription, and give it Contributor there and nothing wider. The pipeline needs to deploy an application, not to create subscriptions.

A pipeline that can reach production must not run on an unreviewed branch. Put the production credentials behind an environment (below), never in a variable group a pull request build can read.


Environments, approvals, and what actually gates a release

An Azure DevOps environment is the object that carries approvals and a deployment history. A deployment job targets one:

- stage: Production
  dependsOn: Test
  condition: succeeded()
  jobs:
  - deployment: Deploy
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: 'azure-production'
              appName: 'my-app'
              package: $(Pipeline.Workspace)/app/*.zip

Add the approval on the environment in the Azure DevOps interface, not in the YAML — approvals are a property of the environment, so a pipeline cannot grant itself one by editing its own file. That distinction is the whole security value of environments, and it is easy to miss because everything else here lives in YAML.

$(Pipeline.Workspace) is where a deployment job downloads its artifact automatically. A plain job does not do this; you need an explicit download step. This trips up nearly everyone converting a working job into a deployment job or the reverse.


Database migrations are the hard part

Everything above is standard for any application. This is the part specific to .NET, and it is where a first pipeline usually causes an outage.

Do not run Database.Migrate() on application startup in production. It is convenient in development and it has three problems in production:

  • Every instance runs it at once during a scale-out or a restart. EF Core takes a lock, so this mostly works, and "mostly" is doing real work in that sentence.
  • The application is already accepting traffic by the time the migration is decided. A migration that takes a lock on a large table takes the site down while it holds it.
  • A failed migration leaves a running application against a schema it does not expect, and the failure appears in application logs rather than in the release.

Run migrations as an explicit deployment step instead, before the new code is live:

- script: |
    dotnet tool install --global dotnet-ef
    dotnet ef database update \
      --project src/MyApp.Data \
      --startup-project src/MyApp.Web \
      --connection "$(MigrationConnectionString)"
  displayName: Apply migrations

Two rules make this safe:

Every migration must be applied against the running previous version without breaking it. Between the migration step and the deploy step, the old code is running against the new schema. Renaming a column in one migration breaks the old version during that window. Add the new column, deploy code that writes both, then drop the old one in a later release. This is more work than a rename and it is the difference between a deploy and an outage.

Generate the SQL and read it before anything runs against production data. dotnet ef migrations script --idempotent produces a script you can review, hand to a DBA, or attach to the release. A migration that is destructive is much easier to see in SQL than in C#.


Configuration that is not in the artifact

Since the artifact is identical everywhere, per-environment values arrive at run time. On Azure App Service, application settings become environment variables and override appsettings.json without a rebuild.

Secrets belong in Key Vault, referenced from an application setting:

@Microsoft.KeyVault(SecretUri=https://my-vault.vault.azure.net/secrets/db-connection/)

The application's managed identity reads it. No secret reaches the pipeline, the repository, or a developer's machine, and rotating one is a change in Key Vault rather than a redeploy.

Name settings so the nesting survives the trip: ConnectionStrings__Default in an environment variable is ConnectionStrings:Default in configuration. The double underscore is the separator on Linux, and getting it wrong produces a null where a value should be, with no error.


Being able to undo it

A release you cannot reverse is not a release, it is a commitment.

App Service deployment slots give you this: deploy to a slot, check it, swap. The swap is a routing change, so reversing it is another swap and takes seconds. Warm the slot before swapping, or the first users after a swap wait for the application to start.

Slots are not free of traps. Application settings marked as deployment slot settings stay with the slot; everything else follows the swap. A connection string that is not marked stays with the code and points production at the test database.

What the pipeline cannot give you is a rollback of the database. If a release included a migration, swapping back leaves the old code against the new schema — which is exactly why the compatibility rule above matters.


If you have none of this today

In order, and each step is useful on its own even if you stop there:

  1. Get the build running in the pipeline, with tests, on every push to main. Do not deploy anything yet. This alone tells you within minutes when the build is broken, which is usually the first thing manual releases lose.
  2. Add a deploy to a non-production environment, using a service connection scoped to that resource group. Deploy on every successful build. This is where you learn what your application depends on that is not in the repository.
  3. Move configuration out of the artifact and into application settings and Key Vault. Nearly every problem in step 4 is a configuration difference nobody knew existed.
  4. Add production behind an approval, deploying the same artifact that reached the test environment.
  5. Add slot-based deployment so a bad release can be reversed without a rebuild.

Steps 1 through 3 remove most of the risk. Teams that stall usually stall by attempting step 4 first, on an application whose configuration nobody has untangled yet.


What this does not solve

A pipeline makes releases repeatable. It does not tell you the application is healthy afterward — that needs something watching it, and a deploy that succeeds while the application throws on every request is a normal outcome without one. What to put in place, and what to alert on.

It also does not decide what your infrastructure is. A pipeline deploying to resources someone created by hand in the portal still depends on nobody having created them by hand differently. Describing the infrastructure in Terraform is the companion piece, and the reason environments can be made identical rather than merely similar: how to put an environment you already have under Terraform.


Get a free 20-minute review — tell me what your release looks like now and I'll tell you where I'd start, and why. Nothing to prepare.

Twenty minutes, and nothing to prepare.

Tell me what you need built or fixed, and I'll tell you where I'd start.

Or send it in writing →

Something this didn’t answer?

Ask it here and I will write back. I'll get back to you within one business day.