Docker Compose: Complete Guide for Developers

Everything you need to go from a single Dockerfile to a multi-service production stack. Services, networks, volumes, environment variables, health checks, and real-world patterns you can copy into your projects today.

What Is Docker Compose

Docker Compose is a tool for defining and running multi-container Docker applications. Instead of starting each container manually with long docker run commands, you describe your entire application stack in a single YAML file and bring it up with one command.

A typical web application needs at least three services: a web server, an application runtime, and a database. Without Compose, you would need to create a network, start each container in the right order, connect them to the network, map ports, mount volumes, and pass environment variables — all through separate CLI commands. With Compose, you declare all of that in docker-compose.yml and run docker compose up.

Docker Compose is built into Docker Desktop and available as a standalone plugin for Docker Engine on Linux. Since Docker Compose V2 (2023), the command is docker compose (with a space), not docker-compose (with a hyphen). The old V1 syntax still works, but V2 is the standard going forward.

Version Note

The top-level version key in docker-compose.yml (e.g., version: "3.8") is now optional and ignored by Docker Compose V2. You can omit it entirely. This guide uses the latest specification without a version key.

The docker-compose.yml File Structure

A Compose file has four top-level keys. You will use services in every project. The other three are needed when your stack grows beyond a single service with default settings.

yaml — Top-Level Structure
services:    # Required. Defines each container in your application.
  web:
    image: nginx:alpine
  api:
    build: ./api
  db:
    image: postgres:16

networks:    # Optional. Custom networks for service isolation.
  frontend:
  backend:

volumes:     # Optional. Named volumes for persistent data.
  db-data:
  cache-data:

configs:     # Optional. External configuration files.
  nginx-conf:
    file: ./nginx.conf

The file must be valid YAML. Indentation matters — use two spaces per level (not tabs). If you are unsure about your YAML syntax, paste it into QTool's YAML Formatter to validate and auto-format it before running docker compose up.

Defining Services

Each key under services creates a container. A service can use a pre-built image from Docker Hub or build from a Dockerfile in your project.

Using a Pre-Built Image

yaml — Service with Image
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    restart: unless-stopped

Building from a Dockerfile

yaml — Service with Build
services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    volumes:
      - ./api/src:/app/src
    restart: unless-stopped

Common Service Properties

Property Purpose Example
image Docker image to use nginx:1.25-alpine
build Path to Dockerfile ./app or {context: ./app, dockerfile: Dockerfile.prod}
ports Map host:container ports "8080:80"
volumes Mount directories or named volumes ./data:/app/data
environment Set environment variables NODE_ENV: production
restart Restart policy unless-stopped, always, on-failure
command Override default command ["npm", "run", "dev"]
depends_on Startup order [db, redis]
networks Attach to networks [frontend, backend]

Networks: Service Communication

Docker Compose creates a default network for your project automatically. All services can reach each other by their service name as a hostname. If your Compose file defines services api and db, the API container can connect to the database at db:5432 without any extra configuration.

Custom networks let you isolate services. A common pattern is separating your frontend-facing services from your backend-only services.

yaml — Custom Networks
services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    networks:
      - frontend

  api:
    build: ./api
    networks:
      - frontend
      - backend

  db:
    image: postgres:16
    networks:
      - backend

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true    # No external access

In this setup, nginx can reach api (both on frontend), and api can reach db (both on backend). But nginx cannot reach db directly — they share no network. The internal: true flag on the backend network also blocks outbound internet access from the database container.

DNS Resolution

Within a Docker Compose network, services discover each other by name. The service name in your Compose file becomes the DNS hostname. Use it in your connection strings: postgres://user:pass@db:5432/mydb, redis://redis:6379, http://api:3000.

Volumes: Persistent Data

Containers are ephemeral by default. When a container is removed, its filesystem is gone. Volumes solve this by storing data outside the container lifecycle.

Named Volumes

Named volumes are managed by Docker. They persist until explicitly deleted with docker volume rm. Use them for database data, uploaded files, and application state.

yaml — Named Volumes
services:
  db:
    image: postgres:16
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: secret

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data

volumes:
  db-data:
  redis-data:

Bind Mounts

Bind mounts map a host directory into the container. Changes on either side are reflected immediately. This is essential for development workflows where you want hot reload without rebuilding the image.

yaml — Bind Mounts for Development
services:
  api:
    build: ./api
    volumes:
      - ./api/src:/app/src          # Source code (live reload)
      - ./api/package.json:/app/package.json
      - /app/node_modules           # Anonymous volume (prevents overwrite)
    command: ["npm", "run", "dev"]

The anonymous volume /app/node_modules (no host path before the colon) prevents your local node_modules from overwriting the container's installed dependencies. This is a critical pattern when using bind mounts with Node.js projects.

Environment Variables and Secrets

There are three ways to pass environment variables to your containers. Each has different use cases.

1. Inline in docker-compose.yml

yaml
services:
  api:
    image: node:20-alpine
    environment:
      NODE_ENV: production
      PORT: 3000
      LOG_LEVEL: info

2. Using an .env File

yaml
services:
  api:
    image: node:20-alpine
    env_file:
      - ./api/.env
      - ./api/.env.local    # Overrides values from .env
.env
DATABASE_URL=postgres://user:pass@db:5432/myapp
REDIS_URL=redis://redis:6379
JWT_SECRET=your-secret-key-here
SMTP_HOST=smtp.example.com
SMTP_PORT=587

3. Variable Interpolation

Use ${VARIABLE} syntax in your Compose file to reference values from your shell or from a .env file in the project root.

yaml — Variable Interpolation
services:
  db:
    image: postgres:${POSTGRES_VERSION:-16}
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    ports:
      - "${DB_PORT:-5432}:5432"

The :- syntax provides a default value. ${POSTGRES_VERSION:-16} uses 16 if POSTGRES_VERSION is not set. This makes your Compose file flexible across environments.

To convert environment variables between formats (e.g., from .env to JSON for your application config), try the YAML to JSON or JSON Formatter tools.

Security Warning

Never commit .env files with real passwords or API keys to version control. Add .env to your .gitignore. For production, use Docker secrets or your cloud provider's secret management service. Encode sensitive values with Base64 if needed for transport, but remember that Base64 is encoding, not encryption.

Build Context and Multi-Stage Builds

The build key controls how Docker Compose builds your images. At its simplest, you point it at a directory containing a Dockerfile.

yaml — Build Options
services:
  api:
    build:
      context: ./api                    # Directory with Dockerfile
      dockerfile: Dockerfile.prod       # Non-default Dockerfile name
      target: production                # Multi-stage build target
      args:
        NODE_VERSION: 20
        BUILD_DATE: 2026-02-14
      cache_from:
        - myapp-api:latest              # Use existing image as cache

Multi-Stage Dockerfile

Multi-stage builds produce smaller production images by separating the build environment from the runtime environment.

Dockerfile — Multi-Stage Node.js
# 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
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]

Your Compose file targets the correct stage:

yaml
services:
  # Development: uses builder stage with source mount
  api-dev:
    build:
      context: ./api
      target: builder
    volumes:
      - ./api/src:/app/src
    command: ["npm", "run", "dev"]

  # Production: uses production stage
  api:
    build:
      context: ./api
      target: production
    restart: unless-stopped

Health Checks and depends_on

depends_on controls startup order, but by default it only waits for the container to start — not for the application inside to be ready. A PostgreSQL container might take several seconds to initialize its data directory after the container is running.

Combine healthcheck with depends_on: condition: service_healthy to wait for actual readiness.

yaml — Health Checks
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s
    volumes:
      - db-data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3

  api:
    build: ./api
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/myapp
      REDIS_URL: redis://redis:6379

volumes:
  db-data:

Now api will not start until both db and redis pass their health checks. The start_period gives slow-starting services (like databases) time to initialize before health checks count as failures.

Health Check Properties

Property Default Purpose
test — Command to run. Exit code 0 = healthy.
interval 30s Time between health checks.
timeout 30s Max time for a single check.
retries 3 Consecutive failures before unhealthy.
start_period 0s Grace period for slow-starting containers.

Essential Docker Compose Commands

These are the commands you will use daily. All of them are run from the directory containing your docker-compose.yml file.

bash — Lifecycle Commands
# Start all services (detached mode)
docker compose up -d

# Start and force rebuild images
docker compose up -d --build

# Stop all services (containers remain)
docker compose stop

# Stop and remove containers, networks
docker compose down

# Stop, remove containers, AND delete volumes (CAUTION: data loss)
docker compose down -v

# Restart a specific service
docker compose restart api
bash — Inspection Commands
# List running services
docker compose ps

# View logs (all services)
docker compose logs

# Follow logs for a specific service
docker compose logs -f api --tail 100

# Show running processes
docker compose top

# Validate compose file
docker compose config
bash — Execution Commands
# Open a shell in a running container
docker compose exec api sh

# Run a one-off command (creates a new container)
docker compose run --rm api npm test

# Scale a service to multiple instances
docker compose up -d --scale worker=3

Use docker compose config before every up to catch YAML syntax errors and unresolved variables. It outputs the fully resolved configuration, showing you exactly what Docker will use. You can also validate your YAML syntax with the YAML Formatter for quick error checking.

Production Best Practices

Development Compose files are not production-ready. Here are the changes you need to make before deploying.

1. Use Specific Image Tags

yaml
# Bad: unpredictable
image: postgres:latest

# Good: pinned version
image: postgres:16.2-alpine

2. Set Resource Limits

yaml
services:
  api:
    build: ./api
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 128M

3. Configure Restart Policies

yaml
services:
  api:
    restart: unless-stopped    # Restarts unless manually stopped
  worker:
    restart: on-failure        # Only restart on non-zero exit
    deploy:
      restart_policy:
        condition: on-failure
        max_attempts: 5
        delay: 10s

4. Use Read-Only Filesystems

yaml
services:
  api:
    read_only: true
    tmpfs:
      - /tmp
      - /app/logs
    security_opt:
      - no-new-privileges:true

5. Separate Dev and Prod Files

Use a base file and environment-specific overrides:

bash
# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
yaml — docker-compose.prod.yml (override)
services:
  api:
    build:
      target: production
    environment:
      NODE_ENV: production
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

6. Configure Logging

Docker stores container logs on disk by default with no size limit. In production, this can fill your disk.

yaml
services:
  api:
    logging:
      driver: json-file
      options:
        max-size: "10m"    # Rotate after 10MB
        max-file: "5"      # Keep 5 rotated files

Full-Stack Example

Here is a production-ready Compose file for a typical web application with Nginx, a Node.js API, PostgreSQL, and Redis.

yaml — docker-compose.yml (Full Stack)
services:
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      api:
        condition: service_healthy
    networks:
      - frontend
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "5m"
        max-file: "3"

  api:
    build:
      context: ./api
      target: production
    expose:
      - "3000"
    env_file:
      - ./api/.env
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
      REDIS_URL: redis://redis:6379
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - frontend
      - backend
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M

  db:
    image: postgres:16.2-alpine
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s
    networks:
      - backend
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 256M

  redis:
    image: redis:7.2-alpine
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "128mb"]
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3
    networks:
      - backend
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 192M

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true

volumes:
  db-data:
  redis-data:

This configuration isolates the database and Redis on an internal network, limits memory usage for each service, includes health checks for proper startup ordering, and uses read-only volume mounts for configuration files. To compare this file with your own configuration, use the Diff Checker to spot differences side by side.

Validate Your YAML Before Deploying

Catch syntax errors, fix indentation, and convert between formats. QTool's YAML tools work entirely in your browser — your config never leaves your machine.

Open YAML Formatter YAML to JSON

Related Tools


Frequently Asked Questions

docker compose up creates and starts containers, networks, and volumes as defined in your docker-compose.yml file. If containers do not exist, it builds images (if build context is specified), creates the containers, and starts them. docker compose start only starts existing containers that were previously created but stopped. If the containers do not exist yet, docker compose start will fail. In most workflows you use docker compose up (with the -d flag for detached mode) to bring your entire stack online, and docker compose start only if you previously ran docker compose stop to pause services without removing them.

Docker Compose supports multiple methods for passing environment variables. You can define them inline in docker-compose.yml using the environment key with key-value pairs. You can reference a .env file using the env_file key, which loads all variables from that file into the container. You can also use variable interpolation in docker-compose.yml with ${VARIABLE_NAME} syntax, which pulls values from your shell environment or a .env file in the same directory as your compose file. For sensitive values like API keys and passwords, use Docker secrets in production or store them in a .env file that is excluded from version control via .gitignore.

depends_on controls the startup order of services in Docker Compose. If service B depends_on service A, Compose will start A before B. However, depends_on only waits for the container to start, not for the application inside it to be ready. A database container might be running but still initializing its data. To wait for actual readiness, use depends_on with a condition set to service_healthy combined with a healthcheck on the dependency. For example, a PostgreSQL service can have a healthcheck that runs pg_isready, and the dependent service uses condition: service_healthy to wait until the database actually accepts connections.

Docker Compose volumes persist data outside of containers so it survives container restarts and removals. There are two types: named volumes and bind mounts. Named volumes (defined in the top-level volumes section) are managed by Docker and stored in Docker's internal storage. They are best for database data, application state, and anything that should persist independently of your host filesystem. Bind mounts map a host directory directly into the container (e.g., ./src:/app/src) and are best for development, where you want live code changes reflected in the container without rebuilding. Use named volumes for production data and bind mounts for development source code.

Yes, Docker Compose can be used in production for single-host deployments. It is well suited for small to medium applications running on a single server. For production use, add restart policies (restart: unless-stopped), configure health checks, use named volumes for persistent data, set resource limits (memory and CPU), avoid bind mounts, use specific image tags instead of latest, and store secrets securely. For multi-host deployments with load balancing, automatic failover, and horizontal scaling, consider Docker Swarm or Kubernetes instead. Many teams use Docker Compose for development and staging, then deploy to Kubernetes in production.

Use docker compose logs to view output from all services, or docker compose logs [service-name] for a specific service. Add -f to follow logs in real time (similar to tail -f). Add --tail 100 to see only the last 100 lines. For debugging, docker compose exec [service-name] sh opens a shell inside a running container. docker compose ps shows the status of all services. docker compose top shows running processes. If a container keeps crashing, check exit codes with docker compose ps -a and inspect logs for error messages. You can also use docker compose config to validate your docker-compose.yml file before starting services.

NT

Christian Bucher

We build free developer tools including YAML formatters, JSON validators, and diff checkers. 269 tool pages, all browser-based, no signup required.

269 Developer Tools, One Place

Browse 269 indexed tool pages with no QTool account required, and inspect the source on GitHub.

Open Source — Free Forever Try Free Tools

Related Tools

CSS Box Shadow Generator · Free JSON Validator · Emoji Picker & Search

Related Tools

Visual JSON Editor - Tree View & Raw Editor · Free JSON to YAML Converter · Free API Mock Server

Related Articles

Built by Miguel

Need a custom tool or website?

From . Delivered in 24-48h. You own the code.

View Services →