.NET CI/CD: Automated Deployment with GitHub Actions and Azure DevOps
# .NET CI/CD: Automated Deployment with GitHub Actions and Azure DevOps
A mature CI/CD pipeline is essential for reliable .NET delivery. It reduces release risk, improves developer feedback loops, and standardizes deployment quality. In this article, I walk through the full anatomy of a production-grade pipeline, from the first commit to a successful deployment, complete with real configuration examples and hard-won lessons.
Why CI/CD Matters for .NET Projects
Manual deployments are error-prone. I have seen teams lose entire afternoons tracking down a deployment issue that turned out to be a missed build step or an environment variable that was set correctly on one developer's machine but nowhere else. CI/CD eliminates that entire category of problems.
A well-designed pipeline gives you:
Recommended Pipeline Flow
A solid .NET CI/CD pipeline follows these stages in order:
Let me show you what each stage looks like in practice.
GitHub Actions Workflow for .NET
GitHub Actions is my go-to for most .NET projects. The YAML-based configuration lives in your repository, making it version-controlled and reviewable like any other code.
Here is a complete workflow that covers build, test, publish, and deployment:
name: .NET CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
DOTNET_VERSION: '8.0.x'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore dependencies
run: dotnet restore --locked-mode
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run unit tests
run: dotnet test --no-build --configuration Release --logger trx --results-directory TestResults/
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: TestResults/
- name: Publish
run: dotnet publish src/MyApp/MyApp.csproj --no-build --configuration Release --output ./publish
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: app-artifact
path: ./publish
deploy-staging:
needs: build-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: app-artifact
path: ./publish
- name: Deploy to Azure Web App (Staging)
uses: azure/webapps-deploy@v3
with:
app-name: myapp-staging
publish-profile: ${{ secrets.AZURE_STAGING_PUBLISH_PROFILE }}
package: ./publish
deploy-production:
needs: build-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: app-artifact
path: ./publish
- name: Deploy to Azure Web App (Production)
uses: azure/webapps-deploy@v3
with:
app-name: myapp-production
publish-profile: ${{ secrets.AZURE_PROD_PUBLISH_PROFILE }}
package: ./publishA few things worth noting here. The `--locked-mode` flag on restore ensures that your `packages.lock.json` file is respected -- no unexpected package version changes mid-pipeline. The `if: always()` on test result uploads means you get test reports even when tests fail, which is invaluable for debugging.
Azure DevOps Pipelines
For organizations already invested in the Microsoft ecosystem, Azure DevOps is a natural fit. It offers deeper integration with Azure services, built-in approval gates, and more granular access controls.
trigger:
branches:
include:
- main
- develop
pool:
vmImage: 'ubuntu-latest'
variables:
buildConfiguration: 'Release'
dotnetVersion: '8.0.x'
stages:
- stage: Build
jobs:
- job: BuildAndTest
steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: $(dotnetVersion)
- script: dotnet restore --locked-mode
displayName: 'Restore dependencies'
- script: dotnet build --configuration $(buildConfiguration) --no-restore
displayName: 'Build solution'
- script: dotnet test --configuration $(buildConfiguration) --no-build --collect:"XPlat Code Coverage"
displayName: 'Run tests with coverage'
- task: PublishCodeCoverageResults@2
inputs:
summaryFileLocation: '**/coverage.cobertura.xml'
- script: dotnet publish src/MyApp/MyApp.csproj --configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)
displayName: 'Publish application'
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: $(Build.ArtifactStagingDirectory)
artifactName: 'app-drop'
- stage: DeployStaging
dependsOn: Build
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop'))
jobs:
- deployment: DeployToStaging
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'MyAzureServiceConnection'
appName: 'myapp-staging'
package: '$(Pipeline.Workspace)/app-drop'In my deployment pipelines, I always set up approval gates between staging and production in Azure DevOps. The built-in environment approval feature is far more reliable than custom scripts. It also gives you a clean audit trail of who approved what and when.
Docker Multi-Stage Build for .NET
Containerization is the standard for .NET deployments today. A multi-stage Dockerfile keeps your images small and your build process clean by separating the SDK (build tools) from the runtime.
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy project files and restore first for better layer caching
COPY *.sln .
COPY src/MyApp/MyApp.csproj src/MyApp/
COPY src/MyApp.Domain/MyApp.Domain.csproj src/MyApp.Domain/
COPY tests/MyApp.Tests/MyApp.Tests.csproj tests/MyApp.Tests/
RUN dotnet restore
# Copy everything else and build
COPY . .
RUN dotnet build --configuration Release --no-restore
# Stage 2: Test
FROM build AS test
RUN dotnet test --configuration Release --no-build --logger trx --results-directory /TestResults
# If tests fail, the build stops here
# Stage 3: Publish
FROM build AS publish
RUN dotnet publish src/MyApp/MyApp.csproj --configuration Release --no-build --output /app/publish /p:UseAppHost=false
# Stage 4: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
# Create a non-root user
RUN adduser --disabled-password --gecos "" appuser
USER appuser
COPY --from=publish /app/publish .
EXPOSE 8080
ENV ASPNETCORE_URLS=http:"code-comment">//+:8080
ENTRYPOINT ["dotnet", "MyApp.dll"]The key optimization here is copying `.csproj` files and running `dotnet restore` before copying the rest of the source code. Docker caches each layer, so if your project files have not changed, the restore step is skipped entirely. In my deployment pipelines, this single trick has cut build times by 40-60% on average.
Running as a non-root user in the final stage is a security best practice. It limits the blast radius if your application is compromised.
Integrating Docker Builds into CI
Add the Docker build and push step to your GitHub Actions workflow:
build-and-push-image:
needs: build-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}Always tag your images with the commit SHA in addition to `latest`. This makes rollbacks trivial -- you just redeploy the previous SHA tag.
Deployment Strategies
Choosing the right deployment strategy depends on your application's tolerance for downtime and the risk level of the release.
Blue/Green Deployment
Blue/green maintains two identical environments. One (blue) serves live traffic while the other (green) receives the new deployment. After verification, traffic switches to green.
In Azure App Service, you get this for free with deployment slots:
- name: Deploy to staging slot
uses: azure/webapps-deploy@v3
with:
app-name: myapp-production
slot-name: staging
package: ./publish
- name: Run smoke tests against staging slot
run: |
response=$(curl -s -o /dev/null -w "%{http_code}" https:"code-comment">//myapp-production-staging.azurewebsites.net/health)
if [ "$response" != "200" ]; then
echo "Smoke test failed with status $response"
exit 1
fi
- name: Swap slots
uses: azure/cli@v2
with:
inlineScript: |
az webapp deployment slot swap \
--resource-group myapp-rg \
--name myapp-production \
--slot staging \
--target-slot productionThe beauty of slot swapping is that if something goes wrong, you just swap back. The previous version is still running in the staging slot.
Canary Deployment
Canary releases route a small percentage of traffic to the new version and gradually increase it as confidence grows. This is ideal for high-traffic services where even a brief outage is unacceptable.
With Azure Front Door or a service mesh like Istio, you can configure traffic splitting:
# Kubernetes canary with Istio VirtualService
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: myapp
spec:
hosts:
- myapp.example.com
http:
- route:
- destination:
host: myapp-stable
port:
number: 80
weight: 90
- destination:
host: myapp-canary
port:
number: 80
weight: 10In my deployment pipelines, I start canary rollouts at 5% traffic and only promote after monitoring error rates and latency for at least 15 minutes. If any key metric degrades, the pipeline automatically rolls back.
Rolling Deployment
Rolling deployments update instances one at a time. In Kubernetes, this is the default strategy:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
template:
spec:
containers:
- name: myapp
image: ghcr.io/myorg/myapp:latest
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5The readiness probe is critical here. Without it, Kubernetes might route traffic to a pod that is still starting up.
Security Scanning in CI
Security cannot be an afterthought. Integrating scans into your pipeline catches vulnerabilities before they reach production.
Dependency Vulnerability Scanning
- name: Check for known vulnerabilities
run: dotnet list package --vulnerable --include-transitive
- name: Run NuGet Audit
run: dotnet restore --force-evaluate /p:NuGetAudit=true /p:NuGetAuditLevel=moderateContainer Image Scanning with Trivy
- name: Scan Docker image for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'Secret Scanning
- name: Scan for secrets in code
uses: trufflesecurity/trufflehog@main
with:
extra_args: --only-verifiedIn my deployment pipelines, I treat security scan failures as hard blockers. A pipeline that allows known critical vulnerabilities into production is worse than no pipeline at all -- it gives a false sense of security.
Common CI/CD Mistakes
Over the years, I have seen the same mistakes repeated across teams. Here are the ones that hurt the most:
1. Not Pinning Action and Image Versions
# Bad -- this can break without warning
uses: actions/checkout@main
# Good -- pinned to a specific version
uses: actions/checkout@v4Unpinned versions mean your pipeline can break on a random Tuesday because an upstream action pushed a breaking change. Always pin versions.
2. Storing Secrets in the Repository
It sounds obvious, but I still encounter connection strings and API keys committed to repos. Use your CI platform's secret management. In GitHub Actions, that means repository or environment secrets. In Azure DevOps, use Azure Key Vault integration.
3. Skipping Tests to "Save Time"
Some teams disable tests in the pipeline because they are slow. The correct fix is to make the tests faster -- parallelize them, separate unit from integration tests, or use test impact analysis. Never skip them.
4. No Rollback Plan
Every deployment should have a tested rollback mechanism. Whether it is a slot swap, a Kubernetes rollback, or a redeployment of the previous artifact, the rollback path should be automated and exercised regularly.
5. Ignoring Pipeline Performance
A pipeline that takes 45 minutes kills developer productivity. Track your pipeline execution time and optimize aggressively. Common wins include:
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-6. Same Pipeline for Every Branch
Feature branches do not need to deploy to staging. Use conditional logic to run only the relevant stages:
deploy-staging:
if: github.ref == 'refs/heads/develop'
deploy-production:
if: github.ref == 'refs/heads/main'Operational Guardrails
Beyond the pipeline itself, you need observability into your release process:
Track these metrics and review them monthly. They tell you more about your team's delivery health than any single tool or configuration.
Conclusion
Effective CI/CD is both technical automation and release governance. A well-built pipeline does more than run commands -- it enforces quality gates, enables safe deployments, and gives the entire team confidence that every release is production-ready. The investment in setting it up properly pays dividends on every single deployment.
I can help design a production-focused CI/CD pipeline for your .NET services.
Related Articles
Building RESTful APIs with ASP.NET Core
Learn the fundamentals of building production-ready REST APIs with ASP.NET Core. Controllers, routing, and best practices.
.NET Testing: Unit, Integration, and E2E Test Strategies
Build a testing strategy for .NET projects. xUnit, Moq, integration testing, and the test pyramid.
Microservices Architecture with .NET: Design and Implementation
Design microservices architecture with .NET. Service communication, Docker, and orchestration strategies.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch