all writing
2026-03-19

A .NET CI/CD pipeline on Azure I'd actually reuse

Build, test, containerize, and deploy an ASP.NET Core app with GitHub Actions and Azure, with the gates that catch bad builds.

A good pipeline is invisible. You push code, and a few minutes later either it's live or you get a clear reason why it isn't. A bad pipeline is a source of anxiety, flaky, slow, and vague about failures. After building a few of these for .NET services, here's the shape I reuse, using GitHub Actions to build and Azure to host.

What the pipeline has to do

Four jobs, in order, each gating the next. Build the code. Run the tests. Build and push a container image. Deploy it. If any stage fails, the ones after it never run, so a failing test can't reach production. That gating is the whole point: it's not automation for its own sake, it's a series of gates that broken code can't pass.

Build and test

The first job restores, builds, and runs the test suite. This is your fast feedback loop, so keep it quick.

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0'
      - run: dotnet restore
      - run: dotnet build --no-restore -c Release
      - run: dotnet test --no-build -c Release --verbosity normal

--no-restore and --no-build on the later steps stop them redoing work the earlier steps already did. Small thing, but it shaves real time off every run, and pipeline speed is a feature. If tests take ten minutes, people stop waiting for them and start merging on hope.

Containerize only if tests pass

Because this job needs the first, it only runs when the build and tests are green. No point building an image for code that doesn't pass.

  containerize:
    needs: build-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/docker-login@v1
        with:
          login-server: myregistry.azurecr.io
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}
      - run: |
          docker build -t myregistry.azurecr.io/myapi:${{ github.sha }} .
          docker push myregistry.azurecr.io/myapi:${{ github.sha }}

The image is tagged with github.sha, the exact commit. Every build is traceable to the code that produced it, and a rollback is just deploying an earlier tag. This is the same reason I never deploy latest to Kubernetes, which I got into in deploying .NET to Kubernetes. A concrete tag is a fact; latest is a guess.

Deploy

The final job deploys the freshly pushed image. It needs the containerize step, so again, it only runs if everything upstream passed.

  deploy:
    needs: containerize
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: azure/webapps-deploy@v3
        with:
          app-name: my-api
          images: myregistry.azurecr.io/myapi:${{ github.sha }}

Note environment: production. In GitHub, a protected environment lets you require a manual approval before this job runs. For anything customer-facing I turn that on, so a human clicks "deploy" after seeing the tests pass. It's the seatbelt for the last step, and it costs nothing until the day it saves you.

Secrets stay out of the repo

Every credential here, the registry password, the Azure publish profile, comes from secrets, never the source. This should be obvious, but leaked credentials in git history are one of the most common real security incidents, and once they're in the history they're hard to fully remove. Set them once in the repo or organization settings and reference them by name.

The gate that matters most

If I could keep only one thing from this pipeline, it's the chain of needs. It enforces an order that human discipline never reliably provides: tests before image, image before deploy. It's not possible to accidentally deploy code whose tests failed, because the deploy job literally cannot start until the test job succeeds. That single guarantee removes a whole class of "how did that get to production" incidents.

Keep it boring

The temptation with CI/CD is to add stages: linting, security scans, performance tests, notifications, the works. All useful, but start with the four jobs that matter and add the rest when you feel their absence. A pipeline nobody understands is a pipeline nobody can fix at 2am when it's blocking a hotfix. Build, test, containerize, deploy, each gating the next, is enough to ship safely. Everything else is an enhancement, not a foundation.