Docker Compose for Local Development: Multi-Container Environments
Run a full application stack locally with a single command — learn Docker Compose's service model, networking, volumes, health checks, environment config, and the patterns that keep local dev close to production.
Before you begin
- Understand Docker basics — images, containers, Dockerfile, port mapping
- Docker Desktop or Docker Engine with the Compose plugin installed
Docker Compose for Local Development: Multi-Container Environments
Most applications aren't a single container. They're an API server, a database, a cache, maybe a background worker. Running each manually with docker run — remembering flags, network names, and startup order — gets tedious immediately. Docker Compose solves this: one YAML file describes the whole stack, one command brings it up.
What Docker Compose Is (and Isn't)
Compose is a tool for defining and running multi-container applications on a single host. It's designed for local development, integration testing, and simple single-server deployments.
It is not Kubernetes. It doesn't do:
- Multi-node scheduling
- Automatic failover
- Rolling updates at scale
- Auto-scaling
For production at scale, that's Kubernetes' job. Compose's value is keeping local development fast and close to the production topology.
Compose File Structure
The file is called docker-compose.yml (or compose.yml — both work). It lives at the root of your project.
1services: # one entry per container
2 api:
3 ...
4 db:
5 ...
6
7networks: # optional — Compose creates a default network
8 ...
9
10volumes: # named volumes
11 ...A Minimal Example — API + Database
1services:
2 api:
3 build: . # Build from Dockerfile in current directory
4 ports:
5 - "3000:3000"
6 environment:
7 - DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
8 depends_on:
9 db:
10 condition: service_healthy # Wait until db is actually ready
11
12 db:
13 image: postgres:16-alpine
14 environment:
15 POSTGRES_DB: myapp
16 POSTGRES_USER: postgres
17 POSTGRES_PASSWORD: secret
18 volumes:
19 - db_data:/var/lib/postgresql/data
20 healthcheck:
21 test: ["CMD-SHELL", "pg_isready -U postgres"]
22 interval: 5s
23 timeout: 5s
24 retries: 5
25
26volumes:
27 db_data:Bring it up:
docker compose up # Foreground, all logs streamed
docker compose up -d # Detached (background)
docker compose up --build # Force rebuild images before startingEssential Commands
1docker compose up -d # Start all services in background
2docker compose down # Stop and remove containers (keeps volumes)
3docker compose down -v # Stop, remove containers AND volumes
4docker compose ps # Show running services
5docker compose logs # All service logs
6docker compose logs -f api # Follow logs for a specific service
7docker compose logs --tail 50 # Last 50 lines from all services
8docker compose exec api bash # Shell inside running service
9docker compose exec db psql -U postgres myapp # Run a command in a service
10docker compose restart api # Restart a specific service
11docker compose pull # Pull latest images for all services
12docker compose build # Build all services with build context
13docker compose build api # Build only the api service
14docker compose stop # Stop containers without removing them
15docker compose start # Start stopped containersNetworking — How Services Find Each Other
Compose automatically creates a network for your project. All services on that network can reach each other by service name.
1services:
2 api:
3 image: myapi
4 # Can connect to 'db' at postgres://postgres:secret@db:5432/myapp
5 # 'db' resolves to the container's IP automatically
6
7 db:
8 image: postgres:16-alpineNo IP addresses, no manual network setup. Service names are the hostnames. This is the same model as Kubernetes Services — db in Compose maps to db.default.svc.cluster.local in K8s.
Custom networks
1services:
2 api:
3 networks:
4 - frontend
5 - backend
6
7 db:
8 networks:
9 - backend # db is only reachable from backend
10
11 nginx:
12 networks:
13 - frontend # nginx is only reachable from frontend
14
15networks:
16 frontend:
17 backend:Using separate networks provides isolation: nginx can't directly reach db, only api can.
Volumes — Persistent and Shared Data
Named volumes (persistent across down)
1services:
2 db:
3 image: postgres:16-alpine
4 volumes:
5 - db_data:/var/lib/postgresql/data # named volume
6
7volumes:
8 db_data: # Compose manages this volumedocker compose down removes containers but keeps db_data. Use docker compose down -v to also delete volumes.
Bind mounts (hot-reload in development)
services:
api:
build: .
volumes:
- ./src:/app/src # Mount local src/ into container
- ./config:/app/configChanges to src/ on your host appear immediately inside the container. Combined with a file watcher (nodemon, air, uvicorn --reload), this gives you hot-reload without rebuilding.
Read-only mounts
volumes:
- ./config/nginx.conf:/etc/nginx/nginx.conf:ro:ro prevents the container from modifying the mounted file.
Environment Variables
Inline in the Compose file
services:
api:
environment:
NODE_ENV: development
LOG_LEVEL: debug
DATABASE_URL: postgresql://postgres:secret@db:5432/myappFrom a .env file
Compose automatically reads .env in the same directory and substitutes ${VARIABLE} references:
# .env
POSTGRES_PASSWORD=secret
API_PORT=30001services:
2 api:
3 ports:
4 - "${API_PORT}:3000"
5 db:
6 environment:
7 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}env_file — load an entire file into a service
services:
api:
env_file:
- .env
- .env.local # Local overrides — add to .gitignoreNever commit .env files with real credentials. Commit .env.example with placeholder values; developers copy it to .env locally.
Health Checks and depends_on
Without health checks, depends_on only waits for the container to start — not for the service inside it to be ready. A database container starts in milliseconds; PostgreSQL takes a few seconds to accept connections.
1services:
2 api:
3 depends_on:
4 db:
5 condition: service_healthy # Wait until db healthcheck passes
6 redis:
7 condition: service_healthy
8
9 db:
10 image: postgres:16-alpine
11 healthcheck:
12 test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
13 interval: 5s
14 timeout: 5s
15 retries: 5
16 start_period: 10s # Grace period before first check
17
18 redis:
19 image: redis:7-alpine
20 healthcheck:
21 test: ["CMD", "redis-cli", "ping"]
22 interval: 5s
23 timeout: 3s
24 retries: 5The three condition values:
service_started— container is running (default, not very useful)service_healthy— healthcheck is passingservice_completed_successfully— for init containers that should exit 0
A Full Stack Example — API + PostgreSQL + Redis + nginx
1services:
2 nginx:
3 image: nginx:alpine
4 ports:
5 - "80:80"
6 volumes:
7 - ./nginx.conf:/etc/nginx/nginx.conf:ro
8 depends_on:
9 - api
10
11 api:
12 build:
13 context: .
14 dockerfile: Dockerfile
15 environment:
16 NODE_ENV: development
17 DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
18 REDIS_URL: redis://redis:6379
19 depends_on:
20 db:
21 condition: service_healthy
22 redis:
23 condition: service_healthy
24 volumes:
25 - ./src:/app/src # Hot-reload
26
27 db:
28 image: postgres:16-alpine
29 environment:
30 POSTGRES_DB: ${POSTGRES_DB}
31 POSTGRES_USER: ${POSTGRES_USER}
32 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
33 volumes:
34 - db_data:/var/lib/postgresql/data
35 - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro # Run on first start
36 healthcheck:
37 test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
38 interval: 5s
39 timeout: 5s
40 retries: 5
41
42 redis:
43 image: redis:7-alpine
44 command: redis-server --appendonly yes
45 volumes:
46 - redis_data:/data
47 healthcheck:
48 test: ["CMD", "redis-cli", "ping"]
49 interval: 5s
50 timeout: 3s
51 retries: 5
52
53volumes:
54 db_data:
55 redis_data:Compose Overrides — Dev vs CI vs Production
You can layer multiple Compose files using -f. The base file describes the service topology; override files customise it per environment.
# docker-compose.yml (base)
services:
api:
image: myapp:${TAG:-latest}
environment:
NODE_ENV: production1# docker-compose.dev.yml (development overrides)
2services:
3 api:
4 build: . # Build locally instead of pulling
5 environment:
6 NODE_ENV: development
7 volumes:
8 - ./src:/app/src # Hot-reload
9 ports:
10 - "9229:9229" # Node debugger port1# docker-compose.ci.yml (CI overrides)
2services:
3 api:
4 build:
5 context: .
6 cache_from:
7 - type=gha # GitHub Actions cache# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# CI
docker compose -f docker-compose.yml -f docker-compose.ci.yml run api npm testCompose also automatically merges docker-compose.override.yml if it exists — useful for per-developer local tweaks (add to .gitignore).
Resource Limits
1services:
2 api:
3 deploy:
4 resources:
5 limits:
6 cpus: "0.5"
7 memory: 512M
8 reservations:
9 cpus: "0.25"
10 memory: 256MNote:
docker composev2 appliesdeploy.resourceslimits via Linux cgroups in standalone mode — no extra flags needed. In Kubernetes, useresources.requests/limitsin pod specs instead; Compose resource limits don't carry over to K8s manifests.
Profiles — Optional Services
1services:
2 api:
3 build: .
4
5 db:
6 image: postgres:16-alpine
7
8 pgadmin:
9 image: dpage/pgadmin4
10 profiles:
11 - tools # Only starts when tools profile is active
12
13 mailhog:
14 image: mailhog/mailhog
15 profiles:
16 - toolsdocker compose up # Starts api + db only
docker compose --profile tools up # Starts api + db + pgadmin + mailhogProfiles let you keep optional development tools (admin UIs, mock services, test databases) in the Compose file without starting them by default.
Common Issues
Port already in use:
docker compose down # Stop previous stack
lsof -i :5432 # Find what's using the portContainer starts but service isn't ready:
Add a healthcheck to the dependency and use condition: service_healthy in depends_on.
Volume data from old run causes issues:
docker compose down -v # Delete containers + volumes
docker compose up -dChanges to Dockerfile not reflected:
docker compose up --build # Force rebuildWhat's Next
You've completed the Container Foundations learning path:
- Docker Fundamentals — images, containers, Dockerfile, volumes, networking
- Docker Multi-Stage Builds — slim images, layer cache, distroless, non-root
- Docker Compose (this tutorial) — multi-container stacks, health checks, environment config
Next: Kubernetes. Pods are containers. Services are your Compose network. ConfigMaps and Secrets replace .env. PersistentVolumeClaims replace named volumes. The mental model transfers directly — Kubernetes is Compose at cluster scale.
Start with the Platform Engineering Roadmap to see how containers fit into the full learning path.
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.