What Is Docker Compose?

Docker Compose is a tool for defining and running multi-container applications. Instead of typing long docker run commands with dozens of flags, you describe your entire stack — services, networks, volumes, secrets — in a single YAML file. One command (docker compose up) brings everything to life.

Compose is the standard way to run local development environments, CI pipelines, and even lightweight production workloads. Understanding its full vocabulary gives you precise control over how your containers behave.


The Reference Compose File

Below is a complete, realistic docker-compose.yml for a web application stack: an Nginx reverse proxy, a Node.js API, a PostgreSQL database, a Redis cache, and a background worker. Every option that matters is present.

# docker-compose.yml
# Compose file format version — defines which features are available.
# As of Compose v2 (Docker Desktop 3.x+), the 'version' key is optional
# but kept here for clarity and backward compatibility.
version: "3.9"

# ─────────────────────────────────────────────
# SERVICES — each entry is a container definition
# ─────────────────────────────────────────────
services:

  # ── 1. NGINX — Reverse Proxy ──────────────────────────────────────────────
  nginx:
    image: nginx:1.25-alpine
    container_name: myapp_nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/certs:/etc/nginx/certs:ro
      - static_files:/var/www/static:ro
    depends_on:
      api:
        condition: service_healthy
    networks:
      - frontend
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    labels:
      com.myapp.role: "proxy"
      com.myapp.team: "infrastructure"

  # ── 2. API — Node.js Application ─────────────────────────────────────────
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
      args:
        NODE_ENV: production
        BUILD_VERSION: "1.4.2"
      target: production
      cache_from:
        - myregistry.io/myapp/api:cache
    image: myregistry.io/myapp/api:1.4.2
    container_name: myapp_api
    restart: unless-stopped
    environment:
      NODE_ENV: production
      PORT: "3000"
      DATABASE_URL: "postgresql://appuser:${DB_PASSWORD}@db:5432/appdb"
      REDIS_URL: "redis://redis:6379"
    env_file:
      - .env
      - .env.production
    ports:
      - "127.0.0.1:3000:3000"
    expose:
      - "3000"
    volumes:
      - static_files:/app/public/static
      - ./logs:/app/logs
    secrets:
      - jwt_secret
      - smtp_credentials
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - frontend
      - backend
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M
    security_opt:
      - no-new-privileges:true
    read_only: true
    tmpfs:
      - /tmp
      - /app/tmp
    user: "1001:1001"
    working_dir: /app
    command: ["node", "server.js"]
    labels:
      com.myapp.role: "api"

  # ── 3. PostgreSQL — Database ──────────────────────────────────────────────
  db:
    image: postgres:16-alpine
    container_name: myapp_db
    restart: unless-stopped
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: "${DB_PASSWORD}"
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    shm_size: "256mb"
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1G
    logging:
      driver: "json-file"
      options:
        max-size: "20m"
        max-file: "5"

  # ── 4. Redis — Cache & Queue ──────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: myapp_redis
    restart: unless-stopped
    command: >
      redis-server
      --requirepass "${REDIS_PASSWORD}"
      --maxmemory 256mb
      --maxmemory-policy allkeys-lru
      --appendonly yes
    volumes:
      - redis_data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "--pass", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 300M

  # ── 5. Worker — Background Jobs ───────────────────────────────────────────
  worker:
    build:
      context: ./api
      dockerfile: Dockerfile
      target: production
    container_name: myapp_worker
    restart: unless-stopped
    command: ["node", "worker.js"]
    environment:
      NODE_ENV: production
      DATABASE_URL: "postgresql://appuser:${DB_PASSWORD}@db:5432/appdb"
      REDIS_URL: "redis://redis:6379"
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - backend
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: "0.5"
          memory: 256M
    scale: 2

# ─────────────────────────────────────────────
# NETWORKS — virtual networks containers attach to
# ─────────────────────────────────────────────
networks:
  frontend:
    driver: bridge
    name: myapp_frontend
    ipam:
      driver: default
      config:
        - subnet: "172.20.0.0/24"
  backend:
    driver: bridge
    name: myapp_backend
    internal: true
    ipam:
      driver: default
      config:
        - subnet: "172.20.1.0/24"

# ─────────────────────────────────────────────
# VOLUMES — persistent data storage
# ─────────────────────────────────────────────
volumes:
  db_data:
    driver: local
    name: myapp_db_data
  redis_data:
    driver: local
    name: myapp_redis_data
  static_files:
    driver: local
    name: myapp_static_files

# ─────────────────────────────────────────────
# SECRETS — sensitive values injected at runtime
# ─────────────────────────────────────────────
secrets:
  jwt_secret:
    file: ./secrets/jwt_secret.txt
  smtp_credentials:
    file: ./secrets/smtp_credentials.txt

Step-by-Step Breakdown

version

version: "3.9"

Declares the Compose file format version. Version 3.x introduced deploy, secrets, and healthcheck condition support. In modern Compose V2 (the docker compose plugin, not the old docker-compose binary), this key is technically optional but good to keep for clarity and backward compatibility.


services

The top-level block. Every key underneath it is a service — Docker Compose’s term for a container definition. Each service maps roughly to one docker run command, but expressed declaratively.


image vs build

# Option A — use a pre-built image from a registry
image: nginx:1.25-alpine

# Option B — build from a local Dockerfile
build:
  context: ./api          # the directory sent to the Docker daemon as build context
  dockerfile: Dockerfile  # which Dockerfile to use (default is 'Dockerfile')
  args:                   # build-time ARG values (not available at runtime)
    NODE_ENV: production
  target: production      # stop at this stage in a multi-stage build
  cache_from:             # pull these images to use as layer cache
    - myregistry.io/myapp/api:cache

You can combine both: build tells Compose how to build the image, and image tells it what to tag the result as. If the image already exists in the registry, build can be skipped with --no-build.


container_name

container_name: myapp_nginx

Sets a fixed, human-readable name for the running container. Without this, Compose generates a name like projectname_nginx_1. A fixed name is useful for scripts and logging, but prevents you from running multiple replicas of the same service (each container must have a unique name).


restart

restart: unless-stopped

Controls what Docker does when a container exits. The options are:

Value Behaviour
no Never restart (default)
always Always restart, even on clean exit
on-failure Restart only if exit code is non-zero
unless-stopped Always restart, except when you explicitly docker stop it

unless-stopped is the right default for production services — it survives reboots and crashes but respects deliberate shutdowns.


ports

ports:
  - "80:80"               # HOST_PORT:CONTAINER_PORT — binds on all interfaces
  - "443:443"
  - "127.0.0.1:3000:3000" # bind only on localhost — not exposed to the network

Maps container ports to the host. The format is [host_ip:]host_port:container_port. Binding to 127.0.0.1 is a useful security measure for services that should only be reachable locally (e.g. the API behind Nginx).


expose

expose:
  - "3000"

Documents which ports the container listens on — but does not publish them to the host. Only other containers on the same network can reach them. Think of it as internal documentation plus a runtime hint to other services.


volumes

# Bind mount — maps a host path into the container
volumes:
  - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro   # :ro = read-only
  - ./logs:/app/logs

# Named volume — managed by Docker, persists across container restarts
  - db_data:/var/lib/postgresql/data
  - static_files:/var/www/static:ro

There are three volume types:

Type Syntax Use case
Bind mount ./host/path:/container/path Config files, source code in dev
Named volume volume_name:/container/path Databases, persistent state
tmpfs via tmpfs: key Ephemeral in-memory scratch space

The :ro suffix makes the mount read-only inside the container — a good security practice for config files.


environment and env_file

environment:
  NODE_ENV: production
  PORT: "3000"
  DATABASE_URL: "postgresql://appuser:${DB_PASSWORD}@db:5432/appdb"

env_file:
  - .env
  - .env.production

environment sets individual variables inline. Values like ${DB_PASSWORD} are interpolated from the shell environment or from a .env file in the same directory as the Compose file.

env_file loads variables from one or more files. Later files override earlier ones. This is the standard way to separate secrets from the Compose file itself.

Never commit .env files containing real credentials to version control.


depends_on

depends_on:
  db:
    condition: service_healthy
  redis:
    condition: service_started

Controls startup order. Without depends_on, Compose starts all services in parallel. With it, a service waits for its dependencies before starting.

The condition key gives you three options:

Condition Meaning
service_started Dependency container has started (no health check)
service_healthy Dependency has passed its healthcheck
service_completed_successfully Dependency exited with code 0 (for init containers)

service_healthy is the right choice for databases — it ensures the DB is actually accepting connections before the API tries to connect.


healthcheck

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
  interval: 30s      # how often to run the check
  timeout: 10s       # how long to wait for a response
  retries: 3         # how many consecutive failures = unhealthy
  start_period: 40s  # grace period after startup before checks count

Defines how Docker determines whether a container is healthy. The test field can be:

  • ["CMD", "cmd", "arg1"] — exec directly (no shell)
  • ["CMD-SHELL", "shell command"] — run via /bin/sh -c
  • ["NONE"] — disable any healthcheck from the image

The start_period is critical for slow-starting services like databases — failures during this window don’t count toward the retry limit.


networks

networks:
  - frontend
  - backend

A service joined to multiple networks can reach containers on all of them. A service on only one network is isolated from containers on other networks — this is how you enforce a security boundary between your public-facing and internal services.

In the reference file, nginx and api share the frontend network. api, db, redis, and worker share the backend network. The database is never on the frontend network, so Nginx cannot directly reach it.


Network definitions

networks:
  frontend:
    driver: bridge          # default driver — creates an isolated virtual network
    name: myapp_frontend    # explicit Docker network name (vs Compose's auto-generated one)
    ipam:
      config:
        - subnet: "172.20.0.0/24"  # CIDR for container IPs on this network

  backend:
    driver: bridge
    internal: true          # blocks all external traffic — containers here cannot reach the internet

The internal: true flag on the backend network is a strong security control. Your database and Redis have no reason to make outbound internet connections. This prevents a compromised container from exfiltrating data or downloading malware.


Volume definitions

volumes:
  db_data:
    driver: local           # store on the Docker host's filesystem
    name: myapp_db_data     # explicit volume name

Named volumes declared here are created automatically by docker compose up. They persist until you explicitly run docker volume rm or docker compose down -v. This is intentional — you don’t want your database wiped every time you restart your stack.


secrets

# In the service definition:
secrets:
  - jwt_secret

# At the top level:
secrets:
  jwt_secret:
    file: ./secrets/jwt_secret.txt

Secrets are mounted as files inside the container at /run/secrets/<secret_name>. Your application reads them from there instead of from environment variables. This is more secure than environment variables because:

  • They don’t appear in docker inspect output
  • They aren’t inherited by child processes
  • They can be rotated without rebuilding the image

In Docker Swarm mode, secrets are encrypted and distributed over TLS — in Compose they use file-based injection, but the application interface is identical.


deploy

deploy:
  replicas: 2
  resources:
    limits:
      cpus: "1.0"     # container cannot use more than 1 CPU core
      memory: 512M    # container is killed if it exceeds this
    reservations:
      cpus: "0.25"    # guaranteed minimum CPU
      memory: 128M    # guaranteed minimum memory

deploy is the resource and scheduling block. In Compose (non-Swarm mode), resources.limits and resources.reservations are fully respected by the Docker runtime. Setting limits prevents a misbehaving container from starving others on the same host.

replicas works with docker compose up --scale or in Swarm mode — in standalone Compose, use scale instead.


security_opt, read_only, user, tmpfs

security_opt:
  - no-new-privileges:true  # prevents privilege escalation via setuid binaries

read_only: true             # root filesystem is read-only

user: "1001:1001"           # run as non-root UID:GID

tmpfs:
  - /tmp                    # writable in-memory scratch directories
  - /app/tmp                # (needed because read_only: true)

These four options together form the container hardening baseline:

  1. no-new-privileges — prevents a process from gaining more privileges than it started with, even via setuid/setgid executables.
  2. read_only — makes the container filesystem immutable. Attackers can’t write backdoors or modify binaries. You grant write access only to specific paths via tmpfs or volumes.
  3. user — running as a non-root user limits blast radius. If the container is compromised, the attacker has UID 1001 rather than root.
  4. tmpfs — mounts an in-memory filesystem for paths that legitimately need to be writable (temp files, PID files, etc.) without persisting anything to disk.

command and working_dir

working_dir: /app
command: ["node", "server.js"]

command overrides the CMD from the Dockerfile. Use the exec form (JSON array) rather than the shell form (string) — it avoids spawning a shell, which means signals like SIGTERM reach your process directly and graceful shutdown works correctly.

working_dir overrides the WORKDIR from the Dockerfile.


logging

logging:
  driver: "json-file"
  options:
    max-size: "10m"   # rotate log file when it hits 10 MB
    max-file: "3"     # keep at most 3 rotated files

Without log rotation, containers writing to json-file (the default driver) will fill your disk over time. Always set max-size and max-file. Other popular drivers include syslog, journald, fluentd, and awslogs.


shm_size

shm_size: "256mb"

Sets the size of /dev/shm (shared memory) inside the container. PostgreSQL uses shared memory heavily for buffer caching. The Docker default is 64 MB, which is often too small for Postgres under load and can cause could not resize shared memory segment errors.


labels

labels:
  com.myapp.role: "api"
  com.myapp.team: "infrastructure"

Arbitrary key-value metadata attached to the container. Used by monitoring tools, log shippers, reverse proxies (Traefik reads labels to configure routing), and docker ps --filter queries.


Common Commands

# Start the full stack (detached)
docker compose up -d

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

# View running services
docker compose ps

# Follow logs from all services
docker compose logs -f

# Follow logs from one service
docker compose logs -f api

# Execute a command inside a running container
docker compose exec api sh

# Scale a service to N replicas
docker compose up -d --scale worker=4

# Stop all services (containers removed, volumes kept)
docker compose down

# Stop and delete all data volumes — DESTRUCTIVE
docker compose down -v

# Validate the compose file syntax
docker compose config

Project Structure

A well-organized project using this Compose file looks like this:

myapp/
├── docker-compose.yml
├── docker-compose.override.yml   # local dev overrides (not committed)
├── .env                          # local env vars (not committed)
├── .env.production               # production env vars (not committed)
├── secrets/
│   ├── jwt_secret.txt
│   └── smtp_credentials.txt
├── nginx/
│   ├── nginx.conf
│   └── certs/
├── api/
│   ├── Dockerfile
│   └── src/
└── db/
    └── init/
        └── 01_schema.sql

docker-compose.override.yml is automatically merged with docker-compose.yml by Compose. Use it to add dev-only settings (bind-mounting source code, enabling debug ports, disabling resource limits) without touching the production file.


Key Takeaways

  • depends_on with condition: service_healthy is the correct way to handle startup ordering — not sleep hacks in entrypoints.
  • Named volumes survive docker compose down. Add -v only when you want to wipe data.
  • internal: true networks are a zero-cost way to prevent your database from having internet access.
  • read_only: true + tmpfs gives you an immutable container filesystem with surgical write permissions.
  • Never put secrets in environment variables if you can use the secrets block instead.
  • Always set log rotation — unbounded json-file logs will eventually fill your disk.