Docker Compose Tutorial: From Beginner to Production (2026)
Docker revolutionized how we deploy applications. Docker Compose revolutionized how we run multi-container applications. If you're using Docker without Compose, you're doing it the hard way.
This guide covers everything from your first docker-compose.yml to production deployment patterns used by real engineering teams.
What Is Docker Compose?
Docker Compose is a tool for defining and running multi-container Docker applications. Instead of running multiple docker run commands with long flags, you define everything in a single YAML file:
services:
web:
image: nginx:alpine
ports:
- "80:80"
api:
image: node:22-alpine
ports:
- "3000:3000"
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
One command brings it all up:
docker compose up -d
That's it. Three containers running and connected.
Installing Docker Compose
Docker Compose V2 is now bundled with Docker Desktop and the Docker CLI plugin:
# Check if it's installed
docker compose version
# Docker Compose V2 (current) uses:
docker compose <command>
# V1 (deprecated) used:
docker-compose <command>
On Linux servers:
# Install Docker Engine (includes Compose plugin)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Verify
docker compose version
Your First Docker Compose Project
Let's build a complete web application stack: Frontend + API + Database + Cache.
Project Structure
myapp/
├── docker-compose.yml
├── frontend/
│ ├── Dockerfile
│ └── ... (Next.js app)
├── api/
│ ├── Dockerfile
│ └── ... (Express app)
└── .env
docker-compose.yml
services:
# Frontend (Next.js)
frontend:
build: ./frontend
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:3001
depends_on:
api:
condition: service_healthy
restart: unless-stopped
# Backend API (Node.js)
api:
build: ./api
ports:
- "3001:3001"
environment:
- PORT=3001
- DATABASE_URL=postgresql://app:secret@db:5432/myapp
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
# PostgreSQL Database
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
volumes:
- db_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# Redis Cache
cache:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- cache_data:/data
restart: unless-stopped
volumes:
db_data:
cache_data:
Run It
# Start everything
docker compose up -d
# View logs
docker compose logs -f
# View specific service logs
docker compose logs -f api
# Check status
docker compose ps
# Stop everything
docker compose down
# Stop and delete volumes (reset database)
docker compose down -v
Core Concepts Explained
Services
A service is a container definition. Each service specifies an image (or build context), ports, environment variables, volumes, and dependencies.
Volumes (Persistent Data)
Containers are ephemeral — when they stop, data is lost. Volumes persist data:
services:
db:
image: postgres:16
volumes:
# Named volume (managed by Docker)
- db_data:/var/lib/postgresql/data
# Bind mount (maps to host directory)
- ./backups:/backups
# Read-only mount
- ./config.ini:/etc/app/config.ini:ro
volumes:
db_data: # Define named volumes at top level
Networks (Service Communication)
Services in the same Compose file can communicate using service names as hostnames:
services:
api:
environment:
# 'db' is the service name — Docker resolves it automatically
- DATABASE_URL=postgresql://user:pass@db:5432/mydb
networks:
- backend
db:
image: postgres:16
networks:
- backend
networks:
backend:
driver: bridge
No need for IP addresses or links. Docker DNS handles everything.
Environment Variables
Three ways to pass environment variables:
services:
api:
# Method 1: Inline
environment:
- PORT=3000
- NODE_ENV=production
# Method 2: From file
env_file:
- .env
- .env.production
# Method 3: Using .env file automatically (Docker Compose reads .env in project root)
environment:
- DATABASE_URL=${DATABASE_URL}
Create a .env file in the same directory:
# .env
DATABASE_URL=postgresql://user:pass@db:5432/mydb
JWT_SECRET=your-secret-key
PORT=3000
Important: Add .env to .gitignore. Never commit secrets.
depends_on (Startup Order)
services:
api:
depends_on:
db:
condition: service_healthy # Wait until db passes health check
cache:
condition: service_started # Wait until cache starts
Without depends_on, Docker starts all services simultaneously. With it, you control the order.
healthcheck
services:
api:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s # Check every 30 seconds
timeout: 10s # Wait 10 seconds for response
retries: 3 # 3 consecutive failures = unhealthy
start_period: 40s # Don't check during first 40 seconds
Health checks let Docker know if your service is actually ready, not just running.
Building Custom Images
Basic Dockerfile
# api/Dockerfile
FROM node:22-alpine
WORKDIR /app
# Install dependencies first (cached layer)
COPY package*.json ./
RUN npm ci --only=production
# Copy application code
COPY . .
# Build
RUN npm run build
# Run as non-root user
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
Multi-Stage Build (Smaller Images)
# Builder stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:22-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
# docker-compose.yml
services:
api:
build:
context: ./api
dockerfile: Dockerfile
target: production # Specify which stage to use
Result: Image size drops from ~1GB to ~150MB.
Development vs Production
Development Compose
# docker-compose.yml (development)
services:
api:
build: ./api
ports:
- "3000:3000"
volumes:
# Hot reload: mount source code
- ./api/src:/app/src
environment:
- NODE_ENV=development
command: npm run dev # Override CMD with dev server
Production Compose
# docker-compose.prod.yml
services:
api:
image: myregistry/api:latest # Pre-built image, not build context
ports:
- "3000:3000"
environment:
- NODE_ENV=production
deploy:
replicas: 3
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.5"
memory: 256M
restart: always
Using Multiple Compose Files
# Development
docker compose -f docker-compose.yml up
# Production (override development settings)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Common Patterns
Reverse Proxy with Nginx
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- frontend
- api
frontend:
build: ./frontend
expose:
- "3000" # Only accessible within Docker network, not host
api:
build: ./api
expose:
- "3001"
Background Worker
services:
api:
build: ./api
ports:
- "3000:3000"
worker:
build: ./api # Same codebase
command: node dist/worker.js # Different entrypoint
environment:
- QUEUE_URL=redis://cache:6379
depends_on:
- cache
Database Migration Runner
services:
migrate:
build: ./api
command: npx prisma migrate deploy
environment:
- DATABASE_URL=postgresql://app:secret@db:5432/myapp
depends_on:
db:
condition: service_healthy
restart: "no" # Run once and exit
api:
build: ./api
depends_on:
migrate:
condition: service_completed_successfully # Wait for migrations
Useful Commands Cheat Sheet
# Build and start
docker compose up -d --build
# Rebuild a single service
docker compose up -d --build api
# Scale a service (multiple instances)
docker compose up -d --scale worker=3
# Execute command in running container
docker compose exec api sh
# View resource usage
docker compose stats
# View logs with timestamps
docker compose logs -ft api
# Restart a single service
docker compose restart api
# Pull latest images
docker compose pull
# Update and restart
docker compose pull && docker compose up -d
# Clean up everything (careful!)
docker compose down -v --rmi all --remove-orphans
Troubleshooting
"Port already in use"
# Find what's using the port
sudo lsof -i :3000
# Kill it
kill -9 <PID>
# Or change the port in docker-compose.yml
ports:
- "3001:3000" # Host 3001 → Container 3000
"No space left on device"
Docker accumulates unused images and volumes:
# Clean unused resources
docker system prune -a --volumes
# Check disk usage
docker system df
Container keeps restarting
# Check logs
docker compose logs api
# Check exit code
docker compose ps -a
# Common causes:
# - Application crashes on startup
# - Missing environment variables
# - Database connection fails (check depends_on and health checks)
# - Permission issues (check user and volume permissions)
Networking issues between services
# Enter a container and test connectivity
docker compose exec api ping db
docker compose exec api curl http://db:5432
docker compose exec api nslookup db
# Check networks
docker network ls
docker network inspect myapp_backend
Security Best Practices
-
Never store secrets in docker-compose.yml. Use
.envfiles or Docker secrets. -
Run as non-root user:
USER node # or create a dedicated user
- Use read-only filesystems where possible:
services:
api:
read_only: true
tmpfs:
- /tmp
- Limit resources:
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
- Use specific image tags, not
latest:
image: node:22.5-alpine # Good
image: node:latest # Bad (unpredictable)
Conclusion
Docker Compose turns complex multi-container deployments into a single declarative file. Once you learn it, you'll never go back to manual docker run commands.
Start simple: define your services in docker-compose.yml, add volumes for persistence, set up health checks, and iterate. The patterns in this guide cover 90% of what you'll encounter in real projects.
For the other 10%, the official documentation is comprehensive and well-maintained.