Beyond the Basics
The Docker Fundamentals guide showed a simple two-service Compose file. Real applications need more: health checks to ensure dependent services are ready, environment variable management, service profiles for development vs testing, and a solid understanding of the commands you’ll use every day.
A Full Multi-Service Stack
Here’s a production-realistic stack: Node.js API + PostgreSQL + Redis + Nginx reverse proxy.
# docker-compose.yml
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
api:
condition: service_healthy
api:
build:
context: .
dockerfile: Dockerfile
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/appdb
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=appdb
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 10s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
Key patterns:
depends_onwithcondition: service_healthywaits for the healthcheck to pass, not just the container to start${DB_PASSWORD}reads from an.envfile or the host environment — never hardcode secrets- Named volumes (
postgres_data,redis_data) persist data across container restarts
Environment Variables and .env Files
Compose automatically loads a .env file from the same directory:
# .env (in the same directory as docker-compose.yml)
DB_PASSWORD=supersecret
REDIS_MAXMEMORY=256mb
NODE_ENV=development
Reference variables in your Compose file with ${VARIABLE_NAME}. Different environments get different .env files:
# Development
docker compose --env-file .env.dev up -d
# Staging
docker compose --env-file .env.staging up -d
Important: Add .env to your .gitignore. Commit a .env.example with placeholder values instead.
# .env.example
DB_PASSWORD=changeme
REDIS_MAXMEMORY=256mb
NODE_ENV=development
Service Profiles
Profiles let you define services that only start when explicitly requested — useful for development tools, test databases, or admin UIs you don’t need in production.
services:
api:
build: .
# No profile = always starts
db:
image: postgres:16-alpine
# No profile = always starts
adminer:
image: adminer
profiles: ["debug"]
ports:
- "8080:8080"
test-db:
image: postgres:16-alpine
profiles: ["test"]
environment:
- POSTGRES_DB=testdb
# Start only default services (api + db)
docker compose up -d
# Start with the debug profile (api + db + adminer)
docker compose --profile debug up -d
# Start with the test profile
docker compose --profile test up -d
Essential Compose Commands
# Start all services in detached (background) mode
docker compose up -d
# Start and rebuild images (use after Dockerfile changes)
docker compose up -d --build
# Stop all services (containers remain)
docker compose stop
# Stop and remove containers, networks (volumes kept)
docker compose down
# Stop and remove containers + volumes (wipes data!)
docker compose down -v
# View logs from all services
docker compose logs -f
# View logs from a specific service
docker compose logs -f api
# Run a command inside a running service container
docker compose exec api sh
docker compose exec db psql -U app -d appdb
# List running services and their status
docker compose ps
# Validate your Compose file without running it
docker compose config
# Scale a specific service (run 3 api instances)
docker compose up -d --scale api=3
Dependency Ordering in Practice
depends_on controls start order, but without health checks it only waits for the container to exist — not for the service inside to be ready. A PostgreSQL container starts in milliseconds, but the database process inside takes several seconds to be ready for connections.
Without health check:
depends_on:
- db # Container started, but postgres might not be ready yet → connection error
With health check:
depends_on:
db:
condition: service_healthy # Waits until pg_isready returns success
Always add health checks to stateful services (databases, caches, message queues) that other services depend on.
Design Compose Files For Humans
A Compose file is documentation as much as configuration. A teammate should be able to open it and understand which services exist, which ports are exposed, which data is persisted, and which environment variables are required.
Keep service names boring and predictable: web, api, db, redis, worker. Avoid clever names that make logs harder to scan. Put related configuration together and prefer explicit named volumes over anonymous volumes so cleanup is intentional.
Environment Variables
Use .env.example to document required variables without committing real secrets. Compose can read a local .env file, but that file should normally stay out of Git. The example file gives new contributors a checklist:
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me
POSTGRES_DB=app
If a value is safe and universal, it can live in the Compose file. If it is secret, machine-specific, or environment-specific, keep it outside the file and document it.
Development Versus Production
Compose is excellent for local development and small internal deployments, but do not assume the exact same file should run production. Local Compose often mounts source code, exposes ports directly, and uses convenient defaults. Production usually needs immutable images, managed secrets, backups, monitoring, and tighter network boundaries.
It is fine to have a development Compose file and a production deployment definition. The important part is knowing which assumptions belong to each environment.
Troubleshooting Service Startup
depends_on controls startup order, not application readiness unless you combine it with health checks. A database container can be “started” before it is ready to accept connections. That is why health checks matter for databases, queues, and caches.
When debugging, inspect logs service by service:
docker compose logs api
docker compose logs db
If a service cannot reach another service, use the Compose service name as the hostname. Inside the Compose network, db should resolve to the database service. You usually do not need to connect to localhost from one container to another.
What I Would Do In Practice
I would keep the local Compose file focused on developer experience: fast startup, stable service names, named volumes, and a clear .env.example. I would add health checks for stateful services and a README section with the three commands people actually need: start, view logs, and reset local data.
Compose is at its best when it turns “install five dependencies” into one readable command. If the file gets too clever, split it, simplify it, or move production concerns to the proper deployment platform.
Common Compose Mistakes
Avoid putting secrets directly in compose.yml. Environment variables are convenient, but the file often gets committed, copied into issue trackers, or shared with teammates. Use .env.example for names and harmless defaults, then keep real secrets local or in a secrets manager.
Do not publish every internal port to the host. Services can talk to each other over the Compose network by service name, so only expose the ports humans or external tools need. Publishing fewer ports reduces conflicts and makes the local environment easier to understand.
Be careful with bind mounts over directories that were populated during image build. A host mount can hide files copied into the image, which makes “it works in CI but not locally” problems confusing. Mount source code intentionally, and use named volumes for database state.
Keep The File Reviewable
A Compose file is part of the codebase, so it should be easy to review. Group related services, use clear service names, and avoid clever YAML features unless they remove real duplication. A teammate should be able to scan the file and understand the app topology.
When the file grows, split by purpose: base services, local development overrides, and optional profiles. That is easier to maintain than one giant file with every possible environment mixed together.