Docker Compose Deep Dive: Every Option Explained
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
.envfiles 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 inspectoutput - 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:
no-new-privileges— prevents a process from gaining more privileges than it started with, even via setuid/setgid executables.read_only— makes the container filesystem immutable. Attackers can’t write backdoors or modify binaries. You grant write access only to specific paths viatmpfsor volumes.user— running as a non-root user limits blast radius. If the container is compromised, the attacker has UID 1001 rather than root.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_onwithcondition: service_healthyis the correct way to handle startup ordering — not sleep hacks in entrypoints.- Named volumes survive
docker compose down. Add-vonly when you want to wipe data. internal: truenetworks are a zero-cost way to prevent your database from having internet access.read_only: true+tmpfsgives you an immutable container filesystem with surgical write permissions.- Never put secrets in environment variables if you can use the
secretsblock instead. - Always set log rotation — unbounded
json-filelogs will eventually fill your disk.