.NET & C# Development · Lesson 89 of 92
Project: Ship OrderFlow — Docker, CI/CD & Zero-Downtime Deploy
The Goal
By the end of this lesson, every push to main will:
- Build the OrderFlow solution
- Run the test suite
- Build a Docker image
- Push it to a container registry
- 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:
# 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.jsondocker-compose — Full Local Stack
# 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:# Start everything
docker compose up -d
# View logs
docker compose logs -f api
# Stop and remove volumes (full reset)
docker compose down -vEnvironment Variables — Never Hardcode Secrets
# .env (in .gitignore — never committed)
JWT_SECRET_KEY=your-256-bit-secret-key-here-generate-with-openssl-rand-base64-32# Generate a strong key
openssl rand -base64 32In production, secrets come from Azure Key Vault or environment variables set by the platform — never from the image itself.
GitHub Actions — CI/CD Pipeline
# .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 }}:latestAzure Container Apps Setup
# 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:
// 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:
// 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
// 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.appKey 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 restoreonly reruns when.csprojfiles change - Never hardcode secrets —
.envlocally, Azure Key Vault / platform secrets in production - docker-compose
depends_onwithhealthcheck— 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/livevs/health/ready— liveness = "is the process alive?", readiness = "can it serve traffic?" — Container Apps needs both
What is the key benefit of a multi-stage Dockerfile for a .NET API?