Docker Compose — Local Multi-Service Development

Docker Compose defines and runs multi-container applications. Instead of manually starting an API container, a SQL Server container, and a Redis container with individual docker run commands (and remembering all the network flags, volume mounts, and environment variables), a docker-compose.yml file declares the entire stack. One command — docker compose up — starts everything in the right order. This is the standard local development environment for the ASP.NET Core Web API in this series: the developer runs the stack, codes against it, and tears it down cleanly.

Docker Compose for the Full Stack

// ── docker-compose.yml ─────────────────────────────────────────────────────

version: '3.8'

services:

  # ── ASP.NET Core Web API ──────────────────────────────────────────────────
  blogapp-api:
    build:
      context: .
      dockerfile: src/BlogApp.Api/Dockerfile
    container_name: blogapp-api
    ports:
      - "8080:8080"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__Default=Server=sqlserver;Database=BlogApp_Dev;User Id=sa;Password=Dev@Password123;TrustServerCertificate=True
      - ConnectionStrings__Redis=redis:6379
      - JwtSettings__SecretKey=dev-only-secret-key-at-least-32-chars
      - JwtSettings__Issuer=http://localhost:8080
    depends_on:
      sqlserver:
        condition: service_healthy
      redis:
        condition: service_healthy

  # ── SQL Server ────────────────────────────────────────────────────────────
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    container_name: blogapp-sqlserver
    ports:
      - "1433:1433"          # expose for SSMS access from host
    environment:
      - SA_PASSWORD=Dev@Password123
      - ACCEPT_EULA=Y
      - MSSQL_PID=Developer  # free Developer edition
    volumes:
      - sqlserver-data:/var/opt/mssql   # persist data across restarts
    healthcheck:
      test: ["CMD", "/opt/mssql-tools/bin/sqlcmd", "-S", "localhost",
             "-U", "sa", "-P", "Dev@Password123", "-Q", "SELECT 1"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s    # SQL Server takes ~20s to start

  # ── Redis ─────────────────────────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: blogapp-redis
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  sqlserver-data:     # named volume — survives docker compose down
Note: The depends_on with condition: service_healthy waits for a service’s healthcheck to pass before starting dependent services. Without this, the API container starts before SQL Server is ready and the application fails to connect. A simple depends_on: sqlserver (without condition) only waits for the container to start, not for SQL Server to be ready to accept connections — the healthcheck condition is the correct way to ensure readiness.
Tip: Use docker-compose.override.yml for development-specific settings that should not be in the base docker-compose.yml. Docker Compose automatically merges the base file with the override file. The override can add volume mounts for hot reload, expose additional debug ports, or set development-only environment variables. The base file serves as the template for CI and production; the override file is for developer convenience and can be gitignored if it contains sensitive values.
Warning: Never use the docker-compose.yml SA_PASSWORD value (or any hardcoded password) in production. The compose file shown is strictly for local development. For production, passwords and secrets come from environment variables, Docker Secrets, or a vault service. Also note that named volumes (sqlserver-data) persist data across docker compose down but are deleted by docker compose down -v — use with awareness in development.

Common Docker Compose Commands

// ── Starting and stopping ─────────────────────────────────────────────────
docker compose up -d              // start all services in background
docker compose up --build -d      // rebuild images then start
docker compose down               // stop and remove containers (data volumes preserved)
docker compose down -v            // stop and remove containers AND volumes (destroys data!)

// ── Viewing logs ──────────────────────────────────────────────────────────
docker compose logs -f            // follow all service logs
docker compose logs -f blogapp-api // follow one service

// ── Running one-off commands ──────────────────────────────────────────────
docker compose exec blogapp-api dotnet ef database update  // run EF migrations
docker compose exec sqlserver sqlcmd -S localhost -U sa -P Dev@Password123

// ── Scaling services ──────────────────────────────────────────────────────
docker compose up --scale blogapp-api=3 -d   // run 3 API instances (needs load balancer)

Common Mistakes

Mistake 1 — Not using healthchecks in depends_on (race condition on startup)

❌ Wrong — API starts before SQL Server is ready, fails to connect, crashes:

depends_on:
  - sqlserver   # only waits for container start, not SQL Server readiness

✅ Correct — use condition: service_healthy with a defined healthcheck.

Mistake 2 — Not using named volumes for SQL Server data (data lost on compose down)

❌ Wrong — anonymous volume or no volume; all database data lost when container stops.

✅ Correct — use a named volume (sqlserver-data:/var/opt/mssql) for persistent development data.

🧠 Test Yourself

A developer runs docker compose down then docker compose up -d. Is the SQL Server data still there?