Docker Compose: Multi-Service Local Development
Master Docker Compose for local development: multi-service environments, networking, volumes, profiles, overrides, hot reload, debugging, and production-like setups.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Introduction
Docker Compose defines and runs multi-container applications locally. You describe services, networks, and volumes in a YAML file, then start everything with one command. This guide walks through service definitions, networking, volumes, profiles, override files, hot reload setups, debugging, and production-like local environments.
Basic docker-compose.yml
# docker-compose.yml
version: "3.9"
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp
- REDIS_URL=redis://redis:6379
volumes:
- .:/app
- /app/node_modules # Anonymous volume prevents host override
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: npm run dev
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
volumes:
db-data:
redis-data:
Networking
# Default network: all services join a default network named after the project
# Services reach each other by service name as hostname
services:
app:
# app reaches db at: postgresql://db:5432
# app reaches redis at: redis://redis:6379
networks:
- default
db:
networks:
- default
# Custom networks for isolation
frontend:
image: nginx:alpine
networks:
- frontend-net
backend:
image: my-backend
networks:
- frontend-net # Can reach frontend
- backend-net # Can reach db
db:
image: postgres:16
networks:
- backend-net # Only backend can reach db
networks:
frontend-net:
driver: bridge
backend-net:
driver: bridge
internal: true # No external access (isolated)
Volumes
services:
app:
volumes:
# Bind mount: host directory → container (hot reload)
- .:/app
- ./config:/app/config:ro # Read-only mount
# Anonymous volume: prevents host override of specific path
- /app/node_modules
# Named volume: persistent data managed by Docker
- app-cache:/app/.cache
db:
volumes:
- db-data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d:ro
# init-scripts run on first start only (when db-data is empty)
volumes:
db-data:
driver: local
app-cache:
Override Files
# docker-compose.yml — Base configuration (shared across environments)
version: "3.9"
services:
app:
build: .
environment:
- NODE_ENV=development
ports:
- "3000:3000"
# docker-compose.override.yml — Local dev overrides (auto-loaded)
# Docker Compose automatically merges: docker-compose.yml + docker-compose.override.yml
version: "3.9"
services:
app:
volumes:
- .:/app
- /app/node_modules
command: npm run dev
environment:
- NODE_ENV=development
- DEBUG=app:*
ports:
- "3000:3000"
- "9229:9229" # Node.js debugger
# docker-compose.prod.yml — Production-like config (explicit -f flag)
# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml up
version: "3.9"
services:
app:
volumes: [] # Remove bind mounts
command: node server.js
environment:
- NODE_ENV=production
ports:
- "80:3000"
restart: always
deploy:
replicas: 3
resources:
limits:
cpus: '1.0'
memory: 512M
Profiles
# Profiles let you start specific groups of services
# docker compose --profile debug up
# docker compose --profile monitoring up
version: "3.9"
services:
app:
# No profile — always starts
build: .
db:
# No profile — always starts
image: postgres:16
mailhog:
# Only starts with --profile debug
image: mailhog/mailhog
profiles: [debug]
ports:
- "1025:1025"
- "8025:8025"
prometheus:
image: prom/prometheus
profiles: [monitoring]
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana
profiles: [monitoring]
ports:
- "3001:3000"
Hot Reload Setup
Node.js with nodemon
services:
app:
build: .
volumes:
- .:/app
- /app/node_modules
command: npx nodemon --watch src --ext ts,json --exec "node --import tsx src/index.ts"
environment:
- NODE_ENV=development
ports:
- "3000:3000"
Python with uvicorn auto-reload
services:
api:
build: .
volumes:
- .:/app
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/src
environment:
- PYTHONUNBUFFERED=1
ports:
- "8000:8000"
Java with Spring Boot DevTools
services:
app:
build: .
volumes:
- ./target:/app/target
command: java -jar -Dspring.devtools.restart.enabled=true target/app.jar
environment:
- SPRING_DEVTOOLS_RESTART_TRIGGER_FILE=/app/target/.trigger
ports:
- "8080:8080"
Multi-Service Full Stack Application
version: "3.9"
services:
frontend:
build: ./frontend
volumes:
- ./frontend:/app
- /app/node_modules
command: npm run dev -- --host 0.0.0.0
ports:
- "5173:5173"
environment:
- VITE_API_URL=http://localhost:3000/api
depends_on:
- backend
backend:
build: ./backend
volumes:
- ./backend:/app
- /app/node_modules
command: npm run dev
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp
- REDIS_URL=redis://redis:6379
- JWT_SECRET=dev-secret
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
worker:
build: ./backend
volumes:
- ./backend:/app
- /app/node_modules
command: npm run worker
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp
- REDIS_URL=redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
- ./db-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "80:80"
depends_on:
- frontend
- backend
volumes:
db-data:
redis-data:
Debugging
Node.js debugging
services:
app:
command: node --inspect=0.0.0.0:9229 src/index.js
ports:
- "3000:3000"
- "9229:9229" # Debugger port
Python debugging with debugpy
services:
api:
command: python -m debugpy --listen 0.0.0.0:5678 --wait-for-client src/main.py
ports:
- "8000:8000"
- "5678:5678"
Java remote debugging
services:
app:
command: java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar
ports:
- "8080:8080"
- "5005:5005"
Useful Commands
# Start all services (foreground)
docker compose up
# Start in background (detached)
docker compose up -d
# Start specific services
docker compose up app db
# Start with profiles
docker compose --profile monitoring up
# Rebuild and start
docker compose up --build
# View logs
docker compose logs -f
docker compose logs -f app
docker compose logs --tail=100 app
# Stop and remove containers
docker compose down
# Stop and remove containers + volumes
docker compose down -v
# Stop and remove containers + volumes + images
docker compose down -v --rmi all
# Run a one-off command
docker compose exec app npm run migrate
docker compose run --rm app npm run test
# Restart a single service
docker compose restart app
# View running services
docker compose ps
# View resource usage
docker compose stats
Best Practices
-
For a deeper guide, see Complete Guide to Docker in Production.
-
Use bind mounts for source code in dev — enables hot reload without rebuilds
-
Use anonymous volumes for
node_modules,.venv— prevents host OS from overriding container packages -
Use
depends_onwithcondition: service_healthy— ensures DB is ready before app starts -
Use override files for environment-specific config — base file + override auto-merged
-
Use profiles for optional services — monitoring, mail catcher, debug tools
-
Set
restart: unless-stoppedfor dev databases — survives machine reboots -
Use
.dockerignore— prevents copyingnode_modules,.git, build artifacts into images -
Pin image versions —
postgres:16-alpinenotpostgres:latest -
Use healthchecks on databases — app waits for readiness instead of failing on connection
-
Use named volumes for persistent data — survives
docker compose down(use-vto remove) -
Keep
docker-compose.ymlin version control — reproducible environments for the whole team
Common Mistakes
- No anonymous volume for
node_modules: bind mount overwrites container-installed packages with host’s (or empty) directory. - Using
depends_onwithout healthcheck: app starts before DB is ready, connection fails on boot. - No
.dockerignore: build context includesnode_modules,.git, large files — slow builds and bloated images. - Using
latesttag: images change unexpectedly between pulls. Pin to specific versions. - No volume for database data:
docker compose downdestroys all data. Use named volumes. - Exposing all ports to host: only expose ports you need to access from the host. Internal services communicate via Docker network.
FAQ
What is the difference between bind mounts and named volumes?
Bind mounts map a host directory to a container path. They enable hot reload but tie the container to the host filesystem. Named volumes are managed by Docker and persist independently of the container. Use bind mounts for source code, named volumes for database data.
How does docker-compose.override.yml work?
Docker Compose automatically merges docker-compose.yml with docker-compose.override.yml if it exists. The override file adds or replaces settings from the base file. Use it for local dev settings. For production, use explicit -f flags: docker compose -f docker-compose.yml -f docker-compose.prod.yml.
What are Docker Compose profiles?
Labels that group services. Services without a profile always start. Services with a profile only start when you pass --profile <name>. Useful for optional services like monitoring, debug tools, or mail catchers.
How do I run database migrations?
Use docker compose exec to run a one-off command in a running container: docker compose exec app npm run migrate. Or use docker compose run for a new container: docker compose run --rm app npm run migrate.
How do I reset my local environment?
docker compose down -v stops and removes containers and volumes. This wipes all data. To keep data, use docker compose down without -v. To rebuild from scratch: docker compose build --no-cache && docker compose up.
Related Resources
GitHub Actions CI/CD: Workflows, Runners, Secrets
Master GitHub Actions for CI/CD: workflows, reusable workflows, composite actions, secrets management, runners, matrix builds, caching, and deployment patterns.
GuideHelm Charts: Structure, Templating, Dependencies, Registry
Master Helm charts for Kubernetes: chart structure, templating, values, dependencies, hooks, libraries, registry management, and production patterns for deployment.
GuideComplete Guide to Docker in Production
Run Docker containers in production with confidence. Covers multi-stage builds, distroless images, health checks, image scanning, resource limits, logging, secrets, multi-arch builds, and container runtime security with practical Dockerfile examples.
GuideDocker for Developers — A Complete Guide
Learn Docker from the ground up: images, containers, Dockerfiles, networks, volumes, and Docker Compose for local development.
GuideKubernetes Basics for Application Developers
Learn the core Kubernetes concepts every developer needs: Pods, Services, Deployments, ConfigMaps, and basic kubectl commands.