Learnixo

Docker Compose · Lesson 4 of 5

Production-Like Local Stack for .NET Development

Docker Compose in Production — Honest Assessment

Docker Compose is appropriate for production when:
  → Small deployment (1-5 services)
  → Single server or VM (no cross-host orchestration needed)
  → Team does not have Kubernetes operational experience
  → Workload does not require per-service auto-scaling

Docker Compose is NOT appropriate for production when:
  → You need auto-scaling (Compose has no horizontal scaling built-in)
  → You need rolling updates with zero downtime (Compose restarts services)
  → You have more than one host node
  → High availability is a requirement (single point of failure)

For most clinical SaaS platforms with under 500 concurrent users:
  Docker Compose on a VM (or Azure App Service with containers) is sufficient.
  You can always migrate to AKS later if you outgrow it.

Production Compose File

YAML
# docker-compose.prod.yml
services:
  prescription-service:
    image: clinical/prescription-service:${IMAGE_TAG}  # from CI/CD pipeline
    restart: unless-stopped
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ConnectionStrings__Clinical=${DB_CONNECTION_STRING}
      - APPLICATIONINSIGHTS_CONNECTION_STRING=${AI_CONNECTION_STRING}
    deploy:
      resources:
        limits:
          cpus: '1.0'    # max 1 CPU core
          memory: 512M   # max 512 MB RAM
        reservations:
          cpus: '0.25'
          memory: 128M
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    logging:
      driver: "json-file"
      options:
        max-size: "50m"
        max-file: "3"
    networks: [backend]

  nginx:
    image: nginx:1.25-alpine
    restart: unless-stopped
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      prescription-service:
        condition: service_healthy
    networks: [frontend, backend]

networks:
  frontend:
  backend:
    internal: true

Environment Variable Injection

Bash
# .env file (NOT committed to source control  add to .gitignore)
IMAGE_TAG=v1.2.3
DB_CONNECTION_STRING=Server=clinical-sql;Database=Clinical;User Id=app;Password=...
AI_CONNECTION_STRING=InstrumentationKey=...;IngestionEndpoint=...
DB_PASSWORD=YourStrongPassword123!
REDIS_PASSWORD=AnotherStrongPassword456!
YAML
# docker-compose.prod.yml reads from .env automatically:
services:
  prescription-service:
    image: clinical/prescription-service:${IMAGE_TAG}
    environment:
      - DB_CONNECTION_STRING=${DB_CONNECTION_STRING}
Bash
# Deploy command:
docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d

# Verify running:
docker compose ps
docker compose logs prescription-service --tail=50 -f

Secrets Management in Compose

YAML
# Docker secrets (Docker Swarm)  not available in standalone Compose
# For standalone Compose, use:

# Option 1: Environment variables from .env file (see above)
# Most common for small deployments

# Option 2: Docker secrets files (without Swarm)
secrets:
  db_password:
    file: ./secrets/db_password.txt  # file on the host  secured by filesystem permissions

services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    secrets:
      - db_password
    environment:
      - SA_PASSWORD_FILE=/run/secrets/db_password
    # Some images support _FILE suffix to read from /run/secrets/

# Option 3: External secret store (Key Vault)  best for production
# App reads secrets from Azure Key Vault via Managed Identity at runtime
# Compose provides only the Key Vault URI:
services:
  prescription-service:
    environment:
      - KeyVaultUri=https://clinical-kv.vault.azure.net/
    # App calls Key Vault at startup  no secrets in compose file

Restart Policies

YAML
restart: "no"              # Never restart (default for development)
restart: always             # Always restart  even on explicit stop
restart: unless-stopped     # Restart unless explicitly stopped by operator (recommended for production)
restart: on-failure         # Restart only on non-zero exit code

# For production services:
restart: unless-stopped
#  Restarts automatically on crash
#  Does NOT restart if you manually ran: docker compose stop prescription-service
#  Survives host VM reboots (if Docker daemon is configured to start on boot)

# Ensure Docker daemon starts on boot (Linux):
# systemctl enable docker

Rolling Deployment Pattern

Bash
# Compose doesn't have native rolling updates — implement with a script

#!/bin/bash
# deploy.sh

NEW_TAG=$1  # e.g., ./deploy.sh v1.2.4

# Pull new image
docker compose pull prescription-service

# Update IMAGE_TAG and restart service
IMAGE_TAG=$NEW_TAG docker compose up -d --no-deps prescription-service

# Wait for health check to pass (max 60 seconds)
for i in $(seq 1 12); do
  STATUS=$(docker inspect --format='{{.State.Health.Status}}' clinical_prescription-service_1)
  if [ "$STATUS" = "healthy" ]; then
    echo "Deployment successful — service is healthy"
    exit 0
  fi
  echo "Waiting for health check... ($STATUS)"
  sleep 5
done

echo "Deployment failed — service did not become healthy"
docker compose logs prescription-service --tail=50
exit 1

Production issue I've seen: A team deployed a clinical system using Docker Compose on a VM with no resource limits. A prescription report endpoint had an N+1 query that loaded thousands of rows. A ward manager ran a report for 6 months of data, the service consumed all available memory, the Linux OOM killer terminated the process, and the entire VM became unresponsive for 10 minutes (SQL Server, Redis, and the API all died together). Adding resource limits (memory: 512M) and the --oom-kill-disable=false flag would have let Docker kill only the offending service, not the entire host. Resource limits are not optional in production — they are the difference between a service failure and a full system outage.


Key Takeaway

Docker Compose is production-appropriate for small single-server deployments — not for multi-node or auto-scaling workloads. Always set resource limits (deploy.resources.limits) to prevent one service from consuming the whole host. Use restart: unless-stopped for production services. Never commit secrets — use .env files excluded from source control, or external secret stores (Key Vault). Implement health checks on every service and make nginx depend on them. For rolling deployments, script the pull-restart-wait-health pattern since Compose has no native rolling update.