42. Docker & Containerization

Building Docker containers for .NET 10 APIs: multi-stage builds, docker-compose, and registry management

About this chapter

Containerize your .NET 10 API using Docker for consistent, portable deployments across development, staging, and production environments.

  • Docker benefits: Consistency, isolation, easy deployment, and scalability
  • Images and containers: Understanding the blueprint and running instance
  • Dockerfile creation: Multi-stage builds for optimized images
  • Docker Compose: Running multi-container applications locally
  • Registry management: Storing and retrieving container images
  • Best practices: Layer caching, minimal images, and security

Learning outcomes:

  • Understand Docker concepts and benefits
  • Write Dockerfiles with multi-stage builds
  • Build and run Docker containers
  • Use Docker Compose for local development
  • Push images to container registries
  • Optimize Docker images for size and build time

42.1 Why Docker?

Without Docker:

Developer: "It works on my machine!"
DevOps: "But it doesn't on production"
         (Different OS, .NET version, libraries)

With Docker:

Developer: "It works on my machine!"
DevOps: "It works everywhere—same container"
        (Consistent environment, dependencies bundled)

Benefits of containerization:

  • Consistency: Same environment dev → staging → production
  • Isolation: API doesn’t interfere with other services
  • Easy deployment: Container image → cloud → running service
  • Scaling: Spawn multiple container instances
  • Version control: Tag images with versions
  • Resource efficiency: Lightweight vs virtual machines

42.2 Docker Fundamentals

Key concepts:

  • Image: Blueprint for a container (like a class)
  • Container: Running instance of an image (like an object)
  • Dockerfile: Instructions to build an image
  • Registry: Storage for images (Docker Hub, Azure Container Registry, etc.)
  • Layer: Each instruction in Dockerfile creates a layer (cached for efficiency)

Analogy:

Dockerfile → Image → Container
Recipe     → Baked  → Cake
                     Good to eat

42.3 Multi-Stage Dockerfile for .NET 10

Single-stage build (large image—not recommended):

FROM mcr.microsoft.com/dotnet/sdk:10.0
WORKDIR /app
COPY . .
RUN dotnet build
RUN dotnet publish -o out
ENTRYPOINT ["dotnet", "out/CommandAPI.dll"]

Problems:

  • Image contains the entire .NET SDK (1+ GB)
  • Unnecessary for running (only need runtime)
  • Security risk (build tools in production)

Multi-stage build (smaller, secure image):

# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS builder
WORKDIR /app

# Copy project files
COPY CommandAPI/CommandAPI.csproj ./CommandAPI/
COPY CommandAPI.Tests/CommandAPI.Tests.csproj ./CommandAPI.Tests/

# Restore dependencies (cached layer)
RUN dotnet restore CommandAPI/CommandAPI.csproj

# Copy source code
COPY CommandAPI/ ./CommandAPI/

# Build the project
RUN dotnet build -c Release --no-restore

# Publish the application
RUN dotnet publish CommandAPI/CommandAPI.csproj \
    -c Release \
    -o /app/published \
    --no-build

# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app

# Copy only the published output (not the SDK)
COPY --from=builder /app/published .

# Expose port
EXPOSE 8080

# Set environment variables
ENV ASPNETCORE_URLS=http://+:8080

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

# Run the application
ENTRYPOINT ["dotnet", "CommandAPI.dll"]

Why multi-stage is better:

Single-stage:    1.2 GB (entire SDK included)
Multi-stage:     200 MB (only runtime needed)

Reduction: 80% smaller!

Layer caching optimization:

# Copy .csproj first (rarely changes)
# This creates a cached layer
COPY CommandAPI/CommandAPI.csproj ./CommandAPI/
RUN dotnet restore CommandAPI/CommandAPI.csproj

# Copy source code (frequently changes)
# This layer rebuilds when code changes, but restore is cached
COPY CommandAPI/ ./CommandAPI/
RUN dotnet build -c Release --no-restore

If code changes, only the build step rebuilds. Restore is cached from previous builds.

42.4 Building Docker Images

Build the image:

# Build image with tag
docker build -t commandapi:1.0 .

# Build with multiple tags
docker build -t commandapi:1.0 -t commandapi:latest .

# Build specific Dockerfile
docker build -t commandapi:1.0 -f Dockerfile.prod .

# Build without cache (force rebuild)
docker build --no-cache -t commandapi:1.0 .

# View build progress
docker build -t commandapi:1.0 --progress=plain .

Verify the image:

# List images
docker images

# Output:
# REPOSITORY    TAG      IMAGE ID       SIZE
# commandapi    1.0      a1b2c3d4       198MB
# commandapi    latest   a1b2c3d4       198MB

42.5 Running Containers Locally

Run a container:

# Basic run
docker run -p 8080:8080 commandapi:1.0

# Run in background (detached mode)
docker run -d -p 8080:8080 --name myapi commandapi:1.0

# Run with environment variables
docker run -d \
  -p 8080:8080 \
  -e ConnectionStrings__PostgreSqlConnection="Host=postgres;Database=commandapi;Username=user;Password=pass" \
  -e ASPNETCORE_ENVIRONMENT=Development \
  commandapi:1.0

# Run with volume mount (for development)
docker run -d \
  -p 8080:8080 \
  -v /home/dev/commandapi/appsettings.Development.json:/app/appsettings.Development.json \
  commandapi:1.0

# Run with resource limits
docker run -d \
  -p 8080:8080 \
  --memory 512m \
  --cpus 1 \
  commandapi:1.0

Test the running container:

# Make request to API
curl http://localhost:8080/api/commands

# View container logs
docker logs myapi

# Execute command inside container
docker exec myapi dotnet --version

# Stop container
docker stop myapi

# Remove container
docker rm myapi

42.6 Docker Compose for Multi-Service Setup

docker-compose.yml for the API:

version: "3.8"

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: commandapi
    ports:
      - "8080:8080"
    environment:
      ASPNETCORE_ENVIRONMENT: Development
      ConnectionStrings__PostgreSqlConnection: "Host=postgres;Database=commandapi;Username=postgres;Password=${DB_PASSWORD}"
      ASPNETCORE_URLS: http://+:8080
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - app-network
    volumes:
      - ./logs:/app/logs

  postgres:
    image: postgres:16
    container_name: commandapi-postgres
    environment:
      POSTGRES_DB: commandapi
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app-network

  redis:
    image: redis:7
    container_name: commandapi-redis
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - app-network

  adminer:
    image: adminer
    container_name: commandapi-adminer
    ports:
      - "8081:8080"
    depends_on:
      - postgres
    networks:
      - app-network

volumes:
  postgres-data:
  redis-data:

networks:
  app-network:
    driver: bridge

Use environment variables securely:

# Create .env file (add to .gitignore)
echo "DB_PASSWORD=SuperSecretPassword123!" > .env

# Run with .env
docker-compose --env-file .env up

# Or set environment variable
export DB_PASSWORD="SuperSecretPassword123!"
docker-compose up

Docker Compose commands:

# Start all services
docker-compose up

# Start in background
docker-compose up -d

# View logs
docker-compose logs

# View logs for specific service
docker-compose logs api

# Stop all services
docker-compose down

# Stop and remove volumes (careful—deletes data!)
docker-compose down -v

# Rebuild images
docker-compose build

# Rebuild and start
docker-compose up --build

# Scale a service (run multiple instances)
docker-compose up -d --scale api=3

42.7 Docker Networking

Networks allow containers to communicate:

networks:
  app-network:
    driver: bridge

Service discovery:

Inside a container, use service name as hostname:

// Inside api container, connect to postgres
var connectionString = "Host=postgres;Database=commandapi;...";
// "postgres" resolves to the postgres container's IP

Port mapping:

ports:
  - "8080:8080"  # host:container

Inside the container, the app listens on port 8080 (internal). From the host, access via port 8080.

Network modes:

# Bridge network (default, recommended)
networks:
  - app-network

# Host network (share host's network stack)
network_mode: host

# No network
network_mode: none

42.8 Volume Management

Volumes persist data after container stops:

volumes:
  postgres-data:
  redis-data:

services:
  postgres:
    volumes:
      - postgres-data:/var/lib/postgresql/data

Types of mounts:

# Named volume (Docker manages storage)
volumes:
  - postgres-data:/var/lib/postgresql/data

# Bind mount (host directory)
volumes:
  - ./logs:/app/logs

# Readonly mount
volumes:
  - ./config.json:/app/config.json:ro

Volume commands:

# List volumes
docker volume ls

# Inspect volume
docker volume inspect commandapi_postgres-data

# Remove unused volumes
docker volume prune

# Remove specific volume
docker volume rm commandapi_postgres-data

42.9 Dockerfile Best Practices

1. Use specific base image tags:

# ❌ WRONG - latest can change unexpectedly
FROM mcr.microsoft.com/dotnet/aspnet:latest

# ✓ CORRECT - pinned version
FROM mcr.microsoft.com/dotnet/aspnet:10.0

2. Run as non-root user:

# Create non-root user
RUN useradd -m -u 1000 appuser
USER appuser

# Set permission for working directory
RUN chown -R appuser:appuser /app

3. Minimize layer count:

# ❌ WRONG - 3 layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean

# ✓ CORRECT - 1 layer
RUN apt-get update && \
    apt-get install -y curl && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

4. Use .dockerignore:

# .dockerignore
*.md
.git
.gitignore
.vs
.vscode
bin
obj
test
*.trx

5. Include health checks:

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

6. Don’t run as root:

# ❌ WRONG
USER root

# ✓ CORRECT
RUN useradd -m appuser
USER appuser

42.10 Image Registry and Pushing

Docker Hub (public):

# Login to Docker Hub
docker login

# Tag image
docker tag commandapi:1.0 yourusername/commandapi:1.0

# Push to Docker Hub
docker push yourusername/commandapi:1.0

# Pull from Docker Hub
docker pull yourusername/commandapi:1.0

Azure Container Registry (private):

# Login to ACR
az acr login --name myregistry

# Tag image
docker tag commandapi:1.0 myregistry.azurecr.io/commandapi:1.0

# Push to ACR
docker push myregistry.azurecr.io/commandapi:1.0

# Pull from ACR
docker pull myregistry.azurecr.io/commandapi:1.0

GitHub Container Registry:

# Login with GitHub token
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin

# Tag image
docker tag commandapi:1.0 ghcr.io/yourusername/commandapi:1.0

# Push
docker push ghcr.io/yourusername/commandapi:1.0

42.11 Debugging Docker Containers

View running containers:

# List running containers
docker ps

# List all containers (including stopped)
docker ps -a

# View container details
docker inspect myapi

View logs:

# View logs
docker logs myapi

# Follow logs (tail -f)
docker logs -f myapi

# View last 100 lines
docker logs --tail 100 myapi

# View logs since specific time
docker logs --since 10m myapi

Execute commands inside container:

# Interactive bash shell
docker exec -it myapi bash

# Run dotnet CLI
docker exec myapi dotnet --version

# Check environment variables
docker exec myapi env | grep ASPNETCORE

Common issues and solutions:

# Port already in use
# Solution: Use different port or stop conflicting container
docker run -p 9080:8080 commandapi:1.0

# Container exits immediately
# Solution: Check logs
docker logs myapi

# Container out of memory
# Solution: Increase memory limit
docker run --memory 1g commandapi:1.0

# Slow startup
# Solution: Check health checks
curl http://localhost:8080/health

42.12 Image Size Optimization

Analyze image layers:

# View image history
docker history commandapi:1.0

# Output shows each layer and size

Reduce image size:

  1. Use alpine images (if available):
# Standard: 352 MB
FROM mcr.microsoft.com/dotnet/aspnet:10.0

# Alpine-based: 200 MB (smaller, but has some limitations)
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
  1. Remove unnecessary files:
RUN apt-get clean && \
    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
  1. Use multi-stage builds (already covered)

  2. Exclude unnecessary files (.dockerignore)

Typical image sizes:

.NET 10 SDK:                1.2 GB
.NET 10 Runtime:            200 MB
Multi-stage release build:  150-250 MB
Alpine-based:               100-150 MB

42.13 What’s Next

You now have:

  • ✓ Understanding why Docker matters
  • ✓ Multi-stage Dockerfiles for .NET 10
  • ✓ Building and running containers
  • ✓ Docker Compose for multi-service setup
  • ✓ Secure environment variable handling
  • ✓ Docker networking and communication
  • ✓ Volume management for persistence
  • ✓ Registry management (Docker Hub, Azure, GitHub)
  • ✓ Debugging and troubleshooting
  • ✓ Image size optimization

Next: Configuration Management—Managing application settings across environments safely.