Docker Compose for Developers: Multi-Container Apps, Networking and Best Practices
Docker Compose is the tool that makes multi-container applications manageable. Instead of juggling multiple docker run commands with dozens of flags, you define your entire stack in one YAML file and bring it up with a single command. This guide covers everything from first principles to production-ready patterns.
The Problem Compose Solves
A typical web application needs several containers: the app server, a database, a cache, maybe a message queue. Starting them manually:
bashdocker run -d --name postgres -e POSTGRES_PASSWORD=secret -p 5432:5432 -v pgdata:/var/lib/postgresql/data postgres:16 docker run -d --name redis -p 6379:6379 redis:7-alpine docker run -d --name app --link postgres --link redis -p 3000:3000 -e DATABASE_URL=... my-app
This is error-prone, hard to reproduce, and painful to configure. Compose replaces all of this with one file.
Basic docker-compose.yml
yamlservices: app: build: . ports: - "3000:3000" environment: - NODE_ENV=development - DATABASE_URL=postgres://postgres:secret@db:5432/myapp - REDIS_URL=redis://redis:6379 depends_on: db: condition: service_healthy redis: condition: service_started volumes: - .:/app - /app/node_modules # prevent host node_modules from overwriting container's db: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: secret POSTGRES_DB: myapp volumes: - pgdata:/var/lib/postgresql/data - ./init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 redis: image: redis:7-alpine volumes: - redisdata:/data volumes: pgdata: redisdata:
Start everything:
bashdocker compose up -d # start in background docker compose logs -f app # follow app logs docker compose down # stop and remove containers docker compose down -v # also remove volumes
Networking in Compose
Compose automatically creates a network for your stack. Services reach each other by service name as the hostname:
yamlservices: app: environment: # "db" is the service name -- resolves to the container IP DATABASE_URL: postgres://postgres:secret@db:5432/myapp REDIS_URL: redis://redis:6379 db: image: postgres:16-alpine redis: image: redis:7-alpine
Your app connects to db:5432, not localhost:5432. This is container-to-container communication on the internal network.
Custom networks
Separate services for security β frontend cannot directly reach the database:
yamlservices: nginx: image: nginx:alpine networks: - frontend ports: - "80:80" app: build: . networks: - frontend - backend # can reach both nginx and db db: image: postgres:16-alpine networks: - backend # only accessible from backend network networks: frontend: backend:
Environment Variables
.env file
Compose automatically loads a .env file in the same directory:
bash# .env POSTGRES_PASSWORD=mysecretpassword APP_PORT=3000 NODE_ENV=development
yamlservices: app: ports: - "${APP_PORT}:3000" environment: - NODE_ENV=${NODE_ENV} db: environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
Never commit .env files with real secrets. Add .env to .gitignore and provide a .env.example.
env_file
Load environment from a file:
yamlservices: app: env_file: - .env - .env.local # overrides .env if it exists
Health Checks
Health checks prevent dependent services from starting before their dependencies are ready:
yamlservices: app: depends_on: db: condition: service_healthy # wait for db to be healthy redis: condition: service_started # just wait for redis to start db: image: postgres:16-alpine healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d myapp"] interval: 5s timeout: 3s retries: 10 start_period: 10s # grace period before health checks begin redis: image: redis:7-alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5
Multi-Stage Build for Production
Keep your production image small and secure by separating build and runtime stages:
dockerfile# Dockerfile # Stage 1: build FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # Stage 2: production runtime FROM node:20-alpine AS production WORKDIR /app # Create non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup COPY package*.json ./ RUN npm ci --only=production && npm cache clean --force COPY /app/dist ./dist USER appuser EXPOSE 3000 CMD ["node", "dist/server.js"]
The production image does not include dev dependencies, source files, or build tools β much smaller and with a smaller attack surface.
Override Files for Different Environments
Use override files to layer configuration for development vs production:
yaml# docker-compose.yml (base -- works for all environments) services: app: image: myapp:${TAG:-latest} environment: - DATABASE_URL=${DATABASE_URL} restart: unless-stopped db: image: postgres:16-alpine volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:
yaml# docker-compose.override.yml (automatically loaded in development) services: app: build: . volumes: - .:/app # mount source for hot reload - /app/node_modules environment: - NODE_ENV=development ports: - "3000:3000" - "9229:9229" # debugger port db: ports: - "5432:5432" # expose DB to host for local tools
yaml# docker-compose.prod.yml (use explicitly in production) services: app: deploy: replicas: 3 resources: limits: memory: 512M environment: - NODE_ENV=production
bash# Development (loads base + override automatically) docker compose up # Production docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Useful Commands
bash# Start services docker compose up -d # Rebuild and restart a specific service docker compose up -d --build app # Scale a service docker compose up -d --scale app=3 # View logs docker compose logs -f docker compose logs -f app # Execute a command in a running container docker compose exec app sh docker compose exec db psql -U postgres myapp # Run a one-off command docker compose run --rm app npm run migrate # Check container status docker compose ps # Stop without removing docker compose stop # Stop and remove containers, networks docker compose down # Stop, remove containers, networks, AND volumes docker compose down -v
Common Interview Questions
Q: What is the difference between depends_on and health checks?
depends_on controls start order β service A starts after service B. But "started" does not mean "ready." A PostgreSQL container starts quickly but may take several seconds before accepting connections. depends_on with condition: service_healthy waits until the health check passes β the container is truly ready to accept connections.
Q: What is the difference between a bind mount and a named volume?
A bind mount maps a specific host path to a container path β great for development (mount source code for live reload). A named volume is managed by Docker, stored in a Docker-controlled location, and persists across container recreations. Use named volumes for persistent data (databases); use bind mounts for development workflows.
Q: How do containers on the same Compose network communicate?
Compose creates a bridge network and registers each service name as a DNS entry. A container in the app service can reach the db service at db:5432 β Docker's internal DNS resolves the service name to the container's IP address.
Practice Docker on Froquiz
Docker and containerization are tested in backend, DevOps, and full-stack interviews. Test your Docker knowledge on Froquiz β covering containers, images, networking, and orchestration.
Summary
- Compose defines multi-container apps in a single YAML file β one command to start the stack
- Services reach each other by service name via Docker's internal DNS
- Use custom networks to isolate services β the database should not be reachable from the public-facing layer
- Health checks with
condition: service_healthyensure dependencies are truly ready before starting dependents - Multi-stage Dockerfiles keep production images small β no dev tools, no source files
- Override files layer configuration per environment β base config + dev or prod overlay
- Named volumes persist data; bind mounts mount host directories for live development