CI/CD for a .NET Application in Azure
CI (continuous integration) builds and tests every change automatically. CD (continuous delivery/deployment) ships the tested build through environments to production in a repeatable, low-risk way. The two common tool choices are GitHub Actions and Azure Pipelines (Azure DevOps). The structure is the same in both.
The pipeline shape
Commit/PR ─► Build ─► Test ─► Publish artifact ─► Deploy Dev ─► Deploy Staging ─► (approval) ─► Production
(build once) (slot / test) (swap)
Principles:
- Build once, deploy the same artifact everywhere. Don't rebuild per environment. Differences come from configuration, not code.
- Run tests on every PR and block merges when they fail (branch protection).
- No stored credentials. Authenticate to Azure with OIDC (workload identity federation).
- Gate production with approvals and automated checks.
Option 1: GitHub Actions
One-time setup: passwordless Azure login
Create an app registration (or user-assigned managed identity), add a federated credential trusting your repo, and grant it a role on the target scope.
# App registration + service principal
az ad app create --display-name gh-deploy-my-app
az ad sp create --id <appId>
# Trust GitHub Actions for the main branch
az ad app federated-credential create --id <appId> --parameters '{
"name": "gh-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:my-org/my-repo:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}'
# Least-privilege role at resource group scope
az role assignment create --assignee <appId> --role "Website Contributor" \
--scope /subscriptions/<sub-id>/resourceGroups/my-rg
The subject must match how the workflow runs. For jobs using a GitHub environment, use environment:<name>. For branch-based runs, use ref:refs/heads/main. You need one federated credential per subject.
Store AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID as repository secrets or variables. They're identifiers, not credentials.
Workflow
name: ci-cd
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write # needed for OIDC
contents: read
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet restore
- run: dotnet build --no-restore -c Release
- run: dotnet test --no-build -c Release --logger trx --collect:"XPlat Code Coverage"
- run: dotnet publish src/MyApi/MyApi.csproj -c Release -o ./publish --no-build
- uses: actions/upload-artifact@v4
with:
name: api
path: ./publish
deploy-staging:
needs: build-test
if: github.ref == 'refs/heads/main' # PRs build and test only
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/download-artifact@v4
with: { name: api, path: ./publish }
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/webapps-deploy@v3
with:
app-name: my-api
slot-name: staging
package: ./publish
- name: Smoke test
run: curl --fail --retry 5 --retry-delay 5 https://my-api-staging.azurewebsites.net/health
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # configure required reviewers on this environment
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Swap staging into production
run: |
az webapp deployment slot swap \
--name my-api --resource-group my-rg \
--slot staging --target-slot production
Notes:
- GitHub Environments (
staging,production) give you required reviewers, wait timers, and environment-scoped secrets. - Deployment slots require Standard tier or above. Slot swaps warm up the staging instance first, so production sees no cold start.
- To roll back, swap again. The previous version is still sitting in the staging slot.
Option 2: Azure Pipelines (YAML)
Create a service connection to Azure using workload identity federation (the recommended option over secret-based connections).
trigger:
branches:
include: [main]
pool:
vmImage: ubuntu-latest
variables:
buildConfiguration: Release
stages:
- stage: Build
jobs:
- job: BuildTest
steps:
- task: UseDotNet@2
inputs:
packageType: sdk
version: 8.0.x
- script: dotnet restore
- script: dotnet build --no-restore -c $(buildConfiguration)
- task: DotNetCoreCLI@2
displayName: Test
inputs:
command: test
projects: '**/*Tests.csproj'
arguments: '-c $(buildConfiguration) --no-build'
- task: DotNetCoreCLI@2
displayName: Publish
inputs:
command: publish
publishWebProjects: true
arguments: '-c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)'
zipAfterPublish: true
- publish: $(Build.ArtifactStagingDirectory)
artifact: drop
- stage: Staging
dependsOn: Build
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployStaging
environment: staging
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: my-service-connection
appType: webAppLinux
appName: my-api
deployToSlotOrASE: true
resourceGroupName: my-rg
slotName: staging
package: $(Pipeline.Workspace)/drop/**/*.zip
- stage: Production
dependsOn: Staging
jobs:
- deployment: DeployProd
environment: production # add Approvals and checks on this environment
strategy:
runOnce:
deploy:
steps:
- task: AzureAppServiceManage@0
inputs:
azureSubscription: my-service-connection
action: Swap Slots
webAppName: my-api
resourceGroupName: my-rg
sourceSlot: staging
targetSlot: production
Azure DevOps Environments provide approvals, checks, and deployment history, the same role that GitHub Environments play.
Infrastructure as Code
Don't create Azure resources by hand. Define them in Bicep (or Terraform) and deploy them through the pipeline, so environments stay consistent and reviewable.
- uses: azure/arm-deploy@v2
with:
scope: resourcegroup
resourceGroupName: my-rg
template: ./infra/main.bicep
parameters: ./infra/prod.bicepparam
failOnStdErr: false
A common pattern is to deploy infrastructure first and the app second. Use what-if on pull requests to preview infrastructure changes before merging.
Database migrations
Don't let the app run migrations on startup in production. Prefer an explicit pipeline step:
# Idempotent SQL script, reviewable and safe to re-run
dotnet ef migrations script --idempotent -o migrate.sql \
--project src/MyApi --startup-project src/MyApi
# Or a self-contained migration bundle
dotnet ef migrations bundle -o efbundle --project src/MyApi
./efbundle --connection "<connection-string>"
Authenticate to SQL with the pipeline's Entra identity rather than a password. Write migrations in an expand/contract style (add new columns and tables first, remove old ones later) so the previous version keeps working during a slot swap or rollback.
Configuration and secrets per environment
- Keep non-secret settings in app settings, applied per slot or environment. Mark settings that must not move during a swap as slot settings (sticky).
- Store secrets in Key Vault and use Key Vault references. Give the app a managed identity to read them.
- Never put secrets in the repo or the build artifact. Use environment-scoped secrets only for pipeline identifiers.
- Use Azure App Configuration with feature flags if you want to release code dark and enable features separately.
Containerized apps
If you deploy containers (Container Apps, AKS, App Service for Containers):
- run: |
az acr login --name myacr
docker build -t myacr.azurecr.io/my-api:${{ github.sha }} .
docker push myacr.azurecr.io/my-api:${{ github.sha }}
- run: |
az containerapp update --name my-api --resource-group my-rg \
--image myacr.azurecr.io/my-api:${{ github.sha }}
Tag images with the commit SHA (never rely on latest), and give the app a managed identity with the AcrPull role. Container Apps revisions let you shift traffic gradually (blue/green or canary). For AKS, use Helm or GitOps (Flux/Argo CD).
Quality and safety gates
Add these as the pipeline matures:
- Static analysis and security: Roslyn analyzers,
dotnet list package --vulnerable, Dependabot or Renovate, CodeQL, secret scanning, and container image scanning (Defender for Cloud). - Integration tests against ephemeral resources (Testcontainers or a dedicated test environment).
- Post-deploy smoke tests and health checks, which block the swap if they fail.
- Load or performance checks on staging for critical paths.
- Automated rollback if post-release metrics or alerts degrade.
- Branch protection: required reviews, required status checks, and no direct pushes to
main.
Good practices
- Keep pipelines in the repo (YAML), versioned and reviewed like code.
- Cache NuGet packages and use
--no-restoreand--no-buildflags to speed up runs. - Use reusable workflows or templates to share logic across services.
- Separate subscriptions or resource groups per environment, and use separate identities with least privilege for each.
- Version your builds (for example, with MinVer or the commit SHA) and surface the version in a
/healthor/versionendpoint, so you can confirm what's running. - Mark deployments in Application Insights (release annotations) so you can correlate regressions with releases.
- Prefer small, frequent releases over large batches. They're easier to test and roll back.
Minimal starting point:
- Create a workflow that restores, builds, and tests on every PR.
- Add OIDC-based Azure login and deploy to a staging slot on merge to
main. - Add a smoke test and a manual approval on a
productionenvironment, then swap slots. - Move infrastructure into Bicep and deploy it from the pipeline.
- Add security scanning, migrations as a controlled step, and alerts tied to releases.