DevOps

Docker Compose Tutorial: From Beginner to Production (2026)

2026-07-05·12 min read
#Docker#Docker Compose#containers#DevOps

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

  1. Never store secrets in docker-compose.yml. Use .env files or Docker secrets.

  2. Run as non-root user:

USER node  # or create a dedicated user
  1. Use read-only filesystems where possible:
services:
  api:
    read_only: true
    tmpfs:
      - /tmp
  1. Limit resources:
deploy:
  resources:
    limits:
      cpus: "0.5"
      memory: 256M
  1. 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.