In-Depth Guide · 8 Sections · DevOps

Docker & CI/CD In Depth

Multi-stage Dockerfiles, local docker-compose stacks, GitHub Actions build/test/deploy to Azure App Service, Key Vault secrets, container health checks and production hardening. Copy-paste .NET 9 patterns.

Dockerfile GitHub Actions Azure App Service Key Vault

Scenario map: Job Reference — Cloud. Platform hosting details: Azure Platform. App code baseline: Minimal API.

01

Why Containerize .NET?

Containers package your app and its runtime so dev, CI, and production run the same bits. For .NET teams that means reproducible builds, faster onboarding, and deploy artifacts that aren't tied to a specific IIS machine.

1.1

What you gain

// Dev laptop     = same image as production
// CI pipeline    = dotnet test inside container → no "works on my machine"
// Rollback       = redeploy previous image tag in seconds
// Scale-out      = App Service / AKS runs N identical containers

// Typical pipeline:
//   git push → build image → run tests → push to registry → deploy tag
1.2

Project layout

MyApi/
  src/MyApi/           # Web project
  tests/MyApi.Tests/
  Dockerfile
  docker-compose.yml
  .dockerignore
  .github/workflows/ci.yml
02

Multi-Stage Dockerfile

Multi-stage builds compile in a SDK image and copy only the published output into a slim ASP.NET runtime image — smaller attack surface, faster deploys.

2.1

Production Dockerfile

# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["src/MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"
COPY src/MyApi/ MyApi/
RUN dotnet publish "MyApi/MyApi.csproj" -c Release -o /app/publish /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
USER $APP_UID
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
2.2

.dockerignore — faster builds

**/bin/
**/obj/
**/.vs/
**/.git/
**/node_modules/
**/*.md
docker-compose*.yml
03

docker-compose — Local Dev Stack

Compose wires your API, SQL Server, and Redis on one network. Developers run docker compose up instead of installing SQL locally.

3.1

docker-compose.yml

services:
  api:
    build: .
    ports: ["8080:8080"]
    environment:
      ConnectionStrings__Default: "Server=sql;Database=MyDb;User=sa;Password=Dev_Passw0rd!;TrustServerCertificate=True"
      ASPNETCORE_ENVIRONMENT: Development
    depends_on:
      sql:
        condition: service_healthy

  sql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "Dev_Passw0rd!"
    ports: ["1433:1433"]
    healthcheck:
      test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "Dev_Passw0rd!" -C -Q "SELECT 1" || exit 1
      interval: 10s
      retries: 10
3.2

Hot reload override (optional)

# docker-compose.override.yml — bind-mount source for dotnet watch
services:
  api:
    build:
      target: build   # stop at SDK stage
    command: dotnet watch run --project MyApi/MyApi.csproj --urls http://0.0.0.0:8080
    volumes:
      - ./src/MyApi:/src/MyApi
04

GitHub Actions — Build & Test

Every push runs restore, build, test, then builds and pushes a versioned container image. Tag images with the git SHA so every deploy is traceable.

4.1

ci.yml workflow

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

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

  docker:
    needs: build-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ${{ secrets.ACR_LOGIN_SERVER }}
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ${{ secrets.ACR_LOGIN_SERVER }}/myapi:${{ github.sha }}
4.2

Testcontainers in CI (optional)

// Integration tests spin real SQL in Docker during dotnet test
public class OrderApiTests : IAsyncLifetime
{
    private readonly MsSqlContainer _sql = new MsSqlBuilder().Build();

    public async Task InitializeAsync() => await _sql.StartAsync();
    public async Task DisposeAsync() => await _sql.DisposeAsync();

    [Fact]
    public async Task GetOrder_returns_200()
    {
        await using var factory = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(b =>
                b.UseSetting("ConnectionStrings:Default", _sql.GetConnectionString()));
        // ...
    }
}
05

Deploy to Azure App Service

App Service can pull your image from Azure Container Registry (ACR). Enable managed identity so the web app authenticates to ACR without storing passwords.

5.1

Deploy job in GitHub Actions

  deploy:
    needs: docker
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      - uses: azure/webapps-deploy@v3
        with:
          app-name: myapi-prod
          images: ${{ secrets.ACR_LOGIN_SERVER }}/myapi:${{ github.sha }}
5.2

Configure container on App Service

# Web app → Deployment Center → Container Registry
# Or CLI:
az webapp config container set \
  --name myapi-prod \
  --resource-group rg-prod \
  --docker-custom-image-name myregistry.azurecr.io/myapi:latest \
  --docker-registry-server-url https://myregistry.azurecr.io

# Always pull specific SHA tag in CI — avoid :latest in production
06

Secrets — Key Vault

Never bake connection strings into images. App Service reads secrets from Key Vault at runtime using Managed Identity — no secrets in GitHub except the federated login to Azure.

6.1

Wire Key Vault in Program.cs

var builder = WebApplication.CreateBuilder(args);

if (!builder.Environment.IsDevelopment())
{
    var vaultUri = new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/");
    builder.Configuration.AddAzureKeyVault(vaultUri, new DefaultAzureCredential());
}

// Secret "ConnectionStrings--Default" in vault maps to ConnectionStrings:Default
var conn = builder.Configuration.GetConnectionString("Default");
6.2

GitHub → Azure OIDC (no long-lived secrets)

# Federated credential on App Registration
# GitHub secret AZURE_CREDENTIALS replaced by:
- uses: azure/login@v2
  with:
    client-id: ${{ secrets.AZURE_CLIENT_ID }}
    tenant-id: ${{ secrets.AZURE_TENANT_ID }}
    subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
07

Health Checks in the Container

App Service and Kubernetes use health endpoints to know when your container is ready and when to restart it. Separate liveness (process up) from readiness (can serve traffic).

7.1

ASP.NET Core health checks

builder.Services.AddHealthChecks()
    .AddSqlServer(builder.Configuration.GetConnectionString("Default")!)
    .AddCheck("self", () => HealthCheckResult.Healthy());

var app = builder.Build();
app.MapHealthChecks("/health/live",  new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });
7.2

Docker HEALTHCHECK + App Service probe

# Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:8080/health/live || exit 1

# App Service → Health check path: /health/ready
# Unhealthy instances removed from load balancer automatically
08

Production — Hardening & Ops

8.1

Production checklist

  • Run as non-root (USER $APP_UID in official .NET images)
  • Pin base image digests for supply-chain reproducibility
  • Scan images in CI (Trivy, Defender for Cloud)
  • Immutable deploys — tag = git SHA, never reuse tags
  • Structured logging to Application Insights / OpenTelemetry
  • Slot swap or gradual traffic before full cutover
8.2

Rollback in one command

# Redeploy last known-good SHA
az webapp config container set \
  --name myapi-prod \
  --resource-group rg-prod \
  --docker-custom-image-name myregistry.azurecr.io/myapi:abc123def

# Or swap staging slot back if bad deploy reached production
Mastery Move — where to go next

1. Azure Platform — Bicep to provision App Service, ACR, and Key Vault as code.

2. Azure for .NET — add Service Bus and Functions to the same pipeline.

3. Testing .NET — gate merges on integration tests with Testcontainers.