Multi-Service Stack Structure#
A typical local development stack has an application, a database, and maybe a cache or message broker. The compose file should read top-to-bottom like a description of your system.
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
env_file:
- .env
volumes:
- ./src:/app/src
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: localdev
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pgdata:depends_on and Healthchecks#
The depends_on field controls startup order, but without a condition it only waits for the container to start, not for the service inside to be ready. A Postgres container starts in under a second, but the database process takes several seconds to accept connections. Use condition: service_healthy paired with a healthcheck to block until the dependency is actually ready.
Common healthcheck patterns for popular services:
# PostgreSQL
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 3s
retries: 5
# MySQL
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 3s
retries: 5
# Redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 3
# HTTP service
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15sThe start_period field is important for services with slow startup. During the start period, failed healthchecks do not count toward the retry limit.
Volume Mounts for Live Reload#
Bind mounts let you edit code on your host and see changes immediately inside the container. The key is mounting only the source directory, not the entire project (which would overwrite node_modules or other build artifacts inside the container).
services:
frontend:
build: ./frontend
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
# Prevent host node_modules from overwriting container's
- /app/node_modules
command: npm run devThe anonymous volume /app/node_modules tells Docker to preserve the container’s node_modules directory even though the parent /app directory has host-mounted content. Without it, the host’s potentially empty or OS-mismatched node_modules would shadow the container’s.
For Go or compiled languages, pair the mount with a file watcher like air or watchexec running inside the container.
Networking#
Compose creates a default bridge network for each project. Services can reach each other by service name as hostname. If your app config connects to db:5432, that resolves within the compose network automatically.
For more control, define explicit networks:
services:
app:
networks:
- frontend
- backend
db:
networks:
- backend
nginx:
networks:
- frontend
networks:
frontend:
backend:This isolates db from nginx — the proxy can reach app but not the database directly. Network aliases let a single service answer to multiple hostnames:
services:
db:
networks:
backend:
aliases:
- postgres
- databaseEnvironment Files#
Keep secrets and configuration out of the compose file. Use .env files per service or per environment:
services:
app:
env_file:
- .env
- .env.local # overrides values from .env
environment:
# Inline values override env_file values
LOG_LEVEL: debugThe .env file in the project root is special: Compose automatically reads it for variable interpolation in the compose file itself (not inside containers). To use it for interpolation:
services:
db:
image: postgres:${POSTGRES_VERSION:-16}-alpineAlways add .env.local and any secret-containing env files to .gitignore. Commit a .env.example with placeholder values.
Profiles for Optional Services#
Profiles let you define services that only start when explicitly requested. This keeps the default docker compose up fast while allowing optional tools on demand.
services:
app:
build: .
ports:
- "8080:8080"
db:
image: postgres:16-alpine
mailhog:
image: mailhog/mailhog
ports:
- "8025:8025"
profiles:
- debug
pgadmin:
image: dpage/pgadmin4
ports:
- "5050:80"
environment:
PGADMIN_DEFAULT_EMAIL: admin@local.dev
PGADMIN_DEFAULT_PASSWORD: admin
profiles:
- debugStart with profiles: docker compose --profile debug up. Services without a profiles key always start. Services with a profile only start when that profile is activated.
Override Files#
The file docker-compose.override.yml is automatically merged with docker-compose.yml when you run docker compose up. Use this for developer-specific settings that should not affect CI or production builds.
# docker-compose.override.yml
services:
app:
build:
target: development
volumes:
- ./src:/app/src
environment:
DEBUG: "true"
ports:
- "9229:9229" # debugger portFor CI, explicitly specify only the base file: docker compose -f docker-compose.yml up. This skips the override file entirely.
For more complex setups, chain multiple files:
docker compose -f docker-compose.yml -f docker-compose.ci.yml upLater files override earlier ones. This lets you maintain a base config, a dev override, and a CI override without duplicating shared service definitions.
Useful Commands#
# Start in background
docker compose up -d
# Rebuild images and start
docker compose up --build
# View logs for a specific service
docker compose logs -f app
# Run a one-off command in a service
docker compose exec db psql -U myapp
# Tear down everything including volumes
docker compose down -v
# View resource usage
docker compose top