Back to blog
Backend Systemsintermediate

Docker, CI/CD & Production Deployment

Containerise OrderFlow with a multi-stage Dockerfile, wire up docker-compose for local parity, and ship to production via a GitHub Actions pipeline that builds, tests, and deploys automatically.

LearnixoApril 14, 20267 min read
.NETDockerGitHub ActionsCI/CDAzureDevOpsOrderFlowC#
Share:𝕏

The Goal

By the end of this lesson, every push to main will:

  1. Build the OrderFlow solution
  2. Run the test suite
  3. Build a Docker image
  4. Push it to a container registry
  5. Deploy to Azure Container Apps

And a docker compose up command will bring up the entire stack — API, SQL Server, Redis — on any developer's machine in under a minute.


The Dockerfile — Multi-Stage Build

A single-stage Dockerfile would ship the SDK (800 MB+) into production. Multi-stage builds leave the SDK behind:

DOCKERFILE
# OrderFlow.Api/Dockerfile

# ── Stage 1: Restore & Build ─────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

# Copy project files first  Docker caches this layer if they haven't changed
# (avoids re-running dotnet restore on every code change)
COPY ["OrderFlow.Api/OrderFlow.Api.csproj",             "OrderFlow.Api/"]
COPY ["OrderFlow.Application/OrderFlow.Application.csproj", "OrderFlow.Application/"]
COPY ["OrderFlow.Domain/OrderFlow.Domain.csproj",           "OrderFlow.Domain/"]
COPY ["OrderFlow.Infrastructure/OrderFlow.Infrastructure.csproj", "OrderFlow.Infrastructure/"]

RUN dotnet restore "OrderFlow.Api/OrderFlow.Api.csproj"

# Copy source after restore (separate layer so restore cache survives code changes)
COPY . .

WORKDIR /src/OrderFlow.Api
RUN dotnet build "OrderFlow.Api.csproj" -c Release -o /app/build

# ── Stage 2: Publish ──────────────────────────────────────────────────────────
FROM build AS publish
RUN dotnet publish "OrderFlow.Api.csproj" \
    -c Release \
    -o /app/publish \
    --no-restore \
    /p:UseAppHost=false

# ── Stage 3: Runtime Image ────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app

# Non-root user — security best practice
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser /app
USER appuser

EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production

COPY --from=publish /app/publish .

ENTRYPOINT ["dotnet", "OrderFlow.Api.dll"]

Image sizes:

  • SDK image: ~800 MB
  • Published runtime image: ~120 MB

.dockerignore

Prevent secrets and build artifacts from leaking into the image:

# .dockerignore (at solution root)
**/.git
**/.vs
**/*.user
**/bin
**/obj
**/*.md
**/Dockerfile*
**/docker-compose*
**/.env
**/appsettings.Development.json
**/secrets.json

docker-compose — Full Local Stack

YAML
# docker-compose.yml
services:

  api:
    build:
      context: .
      dockerfile: OrderFlow.Api/Dockerfile
    ports:
      - "8080:8080"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__DefaultConnection=Server=sql;Database=OrderFlow;User Id=sa;Password=OrderFlow@Dev123;TrustServerCertificate=True
      - ConnectionStrings__Redis=redis:6379
      - Jwt__SecretKey=${JWT_SECRET_KEY}          # from .env file  never hardcode
      - Jwt__Issuer=orderflow-api
      - Jwt__Audience=orderflow-clients
    depends_on:
      sql:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 10s
      timeout: 5s
      retries: 5

  sql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      ACCEPT_EULA: "Y"
      SA_PASSWORD: "OrderFlow@Dev123"
    ports:
      - "1433:1433"
    volumes:
      - sqldata:/var/opt/mssql
    healthcheck:
      test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "OrderFlow@Dev123" -Q "SELECT 1" || exit 1
      interval: 15s
      timeout: 10s
      retries: 10
      start_period: 30s

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redisdata:/data
    command: redis-server --appendonly yes   # persistence on
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  seq:
    image: datalust/seq:latest
    environment:
      ACCEPT_EULA: "Y"
    ports:
      - "5341:80"
    volumes:
      - seqdata:/data

volumes:
  sqldata:
  redisdata:
  seqdata:
Bash
# Start everything
docker compose up -d

# View logs
docker compose logs -f api

# Stop and remove volumes (full reset)
docker compose down -v

Environment Variables — Never Hardcode Secrets

Bash
# .env  (in .gitignore  never committed)
JWT_SECRET_KEY=your-256-bit-secret-key-here-generate-with-openssl-rand-base64-32
Bash
# Generate a strong key
openssl rand -base64 32

In production, secrets come from Azure Key Vault or environment variables set by the platform — never from the image itself.


GitHub Actions — CI/CD Pipeline

YAML
# .github/workflows/ci-cd.yml
name: OrderFlow CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}/orderflow-api

jobs:
  # ─── Job 1: Build & Test ────────────────────────────────────────────────────
  build-and-test:
    runs-on: ubuntu-latest

    services:
      # SQL Server for integration tests
      mssql:
        image: mcr.microsoft.com/mssql/server:2022-latest
        env:
          ACCEPT_EULA: "Y"
          SA_PASSWORD: "Test@Password123"
        ports:
          - 1433:1433
        options: >-
          --health-cmd "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Test@Password123' -Q 'SELECT 1'"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 10

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET 9
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "9.0.x"

      - name: Restore dependencies
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore -c Release

      - name: Run unit tests
        run: dotnet test OrderFlow.Tests.Unit --no-build -c Release --verbosity normal

      - name: Run integration tests
        run: dotnet test OrderFlow.Tests.Integration --no-build -c Release --verbosity normal
        env:
          ConnectionStrings__DefaultConnection: "Server=localhost;Database=OrderFlow_Test;User Id=sa;Password=Test@Password123;TrustServerCertificate=True"
          ConnectionStrings__Redis: "localhost:6379"
          Jwt__SecretKey: "ci-test-secret-key-not-used-in-production"

  # ─── Job 2: Build & Push Docker Image ──────────────────────────────────────
  docker:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'   # only on main, not PRs

    permissions:
      contents: read
      packages: write

    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=,suffix=,format=short
            type=raw,value=latest

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: OrderFlow.Api/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha    # GitHub Actions cache  faster builds
          cache-to: type=gha,mode=max

  # ─── Job 3: Deploy to Azure Container Apps ──────────────────────────────────
  deploy:
    needs: docker
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production    # requires manual approval in GitHub settings

    steps:
      - name: Azure login
        uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: Deploy to Container Apps
        uses: azure/container-apps-deploy-action@v1
        with:
          resourceGroup: rg-orderflow-prod
          containerAppName: ca-orderflow-api
          imageToDeploy: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

Azure Container Apps Setup

Bash
# One-time setup  run these from Azure CLI

# Resource group
az group create --name rg-orderflow-prod --location uksouth

# Container Apps environment
az containerapp env create \
  --name cae-orderflow \
  --resource-group rg-orderflow-prod \
  --location uksouth

# Create the container app
az containerapp create \
  --name ca-orderflow-api \
  --resource-group rg-orderflow-prod \
  --environment cae-orderflow \
  --image ghcr.io/your-org/orderflow-api:latest \
  --target-port 8080 \
  --ingress external \
  --min-replicas 1 \
  --max-replicas 5 \
  --cpu 0.5 \
  --memory 1Gi \
  --secrets \
    jwt-secret=your-production-secret \
    db-connection=your-production-connection-string \
  --env-vars \
    ASPNETCORE_ENVIRONMENT=Production \
    "Jwt__SecretKey=secretref:jwt-secret" \
    "ConnectionStrings__DefaultConnection=secretref:db-connection"

Health Checks in Production

OrderFlow already has a /health endpoint. Add detailed checks for each dependency:

C#
// OrderFlow.Api/Extensions/HealthCheckExtensions.cs
public static IServiceCollection AddOrderFlowHealthChecks(
    this IServiceCollection services,
    IConfiguration configuration)
{
    services.AddHealthChecks()
        .AddDbContextCheck<AppDbContext>(
            name: "database",
            failureStatus: HealthStatus.Unhealthy,
            tags: ["db", "sql"])
        .AddRedis(
            configuration.GetConnectionString("Redis")!,
            name: "redis",
            failureStatus: HealthStatus.Degraded,
            tags: ["cache", "redis"])
        .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]);

    return services;
}

// Map endpoints
app.MapHealthChecks("/health",        new HealthCheckOptions { Predicate = _ => true });
app.MapHealthChecks("/health/live",   new HealthCheckOptions { Predicate = c => c.Tags.Contains("live") });
app.MapHealthChecks("/health/ready",  new HealthCheckOptions { Predicate = c => c.Tags.Contains("db") || c.Tags.Contains("cache") });

Container Apps hits /health/live (liveness) and /health/ready (readiness) automatically.


Database Migrations in Production

Never run dotnet ef database update in production pipelines — it's slow and risky. Instead, apply migrations at startup:

C#
// OrderFlow.Api/Extensions/MigrationExtensions.cs
public static async Task ApplyMigrationsAsync(this WebApplication app)
{
    using var scope = app.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

    var pendingMigrations = await db.Database.GetPendingMigrationsAsync();

    if (pendingMigrations.Any())
    {
        app.Logger.LogInformation(
            "Applying {Count} pending migrations...", pendingMigrations.Count());

        await db.Database.MigrateAsync();

        app.Logger.LogInformation("Migrations applied successfully.");
    }
}

// In Program.cs — runs on every startup, safe to call repeatedly
await app.ApplyMigrationsAsync();

For zero-downtime deployments, migrations must be backward-compatible — new nullable columns, no renames, no column drops until the old code is fully retired.


Serilog — Structured Logs for Production

C#
// appsettings.Production.json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Warning",
      "Override": {
        "OrderFlow": "Information",
        "Microsoft.Hosting.Lifetime": "Information"
      }
    },
    "WriteTo": [
      {
        "Name": "Console",
        "Args": {
          "formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog.Formatting.Compact"
        }
      }
    ],
    "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
  }
}

JSON logs are ingested by Azure Monitor, Datadog, or any log aggregator without parsing.


The Full Production Flow

Developer pushes to main
        │
        ▼
  GitHub Actions
        │
  ┌─────┴──────┐
  │ Build +     │
  │ Test        │← Integration tests hit real SQL + Redis containers
  └─────┬──────┘
        │ pass
        ▼
  Build Docker image
  Push to ghcr.io
        │
        ▼
  Deploy to Azure Container Apps
        │
  ┌─────┴──────┐
  │  Startup:  │
  │  Migrate   │← ApplyMigrationsAsync
  │  Seed      │← Roles, admin user
  │  Health ✓  │
  └────────────┘
        │
        ▼
  Live at https://api.orderflow.app

Key Takeaways

  • Multi-stage Dockerfile — SDK (800 MB) stays in build stage; runtime image ships at ~120 MB
  • Copy project files before source — Docker layer caching means dotnet restore only reruns when .csproj files change
  • Never hardcode secrets.env locally, Azure Key Vault / platform secrets in production
  • docker-compose depends_on with healthcheck — the API won't start until SQL Server and Redis are actually ready
  • GitHub Actions caches (cache-from: type=gha) cut rebuild times dramatically on repeated pushes
  • Apply migrations at startup not in the pipeline — safer, avoids separate migration step in deployment
  • /health/live vs /health/ready — liveness = "is the process alive?", readiness = "can it serve traffic?" — Container Apps needs both

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.