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.
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.
// 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
MyApi/
src/MyApi/ # Web project
tests/MyApi.Tests/
Dockerfile
docker-compose.yml
.dockerignore
.github/workflows/ci.yml
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
Compose wires your API, SQL Server, and Redis on one network. Developers run docker compose up instead of installing SQL locally.
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
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.
# .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()));
// ...
}
}
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
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 }}
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
- 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.