← Back to Blog DevOps

Docker Compose Complete Guide: Services, Volumes, Networks & Best Practices (2026)

🐳 Docker & Permissions Tools — 100% Client-Side, No Uploads

Build, validate, and debug Docker configurations without uploading anything:

1. What is Docker Compose?

Docker Compose is a tool for defining and running multi-container Docker applications. Instead of typing long docker run commands with dozens of flags for each container, you describe your entire application stack — web servers, databases, caches, workers — in a single docker-compose.yml file and bring everything up with one command: docker compose up.

The key insight is declarative infrastructure. You don't tell Docker how to start containers step by step. You describe what your stack looks like — which services exist, what images they use, which ports they expose, how they connect — and Compose figures out the execution order, creates networks, mounts volumes, and starts everything.

Compose Specification vs Legacy Versions

Docker Compose has gone through multiple version schemes that confuse developers to this day. Here is the complete history:

EraCLI CommandFile FormatStatus in 2026
V1 (Python)docker-compose (hyphen)version: "2" / "3"❌ EOL June 2023
V2 (Go plugin)docker compose (space)Compose Specification✅ Current standard

The version key is obsolete. As of Docker Compose v2.20+ and the Compose Specification, the version: top-level key is no longer required and is silently ignored. Remove it from new files. If you see version: "3.8" in a tutorial, that tutorial is outdated.

When to Use Docker Compose

  • Local development — spin up your entire stack (app + database + cache + queue) with one command.
  • CI/CD testing — create isolated test environments with real databases instead of mocks.
  • Single-server deployments — run production workloads on a single VPS or dedicated server.
  • Prototyping — try out new services (Elasticsearch, MinIO, Grafana) without installing anything on your host.

Docker Compose is not designed for multi-host orchestration. If you need containers distributed across multiple servers with automatic scaling, load balancing, and rolling updates, use Kubernetes or Docker Swarm. Compose is for single-host deployments.

2. Docker Run vs Docker Compose

Every docker run flag has a direct equivalent in docker-compose.yml. The difference is ergonomic: docker run is imperative (you execute commands one at a time), while Compose is declarative (you describe the desired state once and let Compose handle execution).

# docker run (imperative — one container at a time)
docker run -d \
  --name my-app \
  -p 3000:3000 \
  -e NODE_ENV=production \
  -e DATABASE_URL=postgres://app:secret@db:5432/myapp \
  -v ./uploads:/app/uploads \
  --network app-network \
  --restart unless-stopped \
  my-app:latest

# vs. Docker Compose (declarative — entire stack)
# docker compose up -d
# ↑ starts web + db + cache in one command
docker run FlagCompose EquivalentExample
-p 3000:3000ports:- "3000:3000"
-v ./data:/datavolumes:- ./data:/data
-e KEY=valueenvironment:- KEY=value
--name my-appService key nameservices: my-app:
--network netnetworks:- net
--restart alwaysrestart:restart: always
-ddocker compose up -dDetached mode

Have an existing docker run command you want to convert? Use our Docker Run to Compose Converter — paste your command and get a properly formatted docker-compose.yml instantly, entirely in your browser.

3. The docker-compose.yml File Structure

A Compose file has four top-level keys. Only services is required:

# Top-level structure of docker-compose.yml
services:    # Required — defines your containers
  web: ...
  db: ...

volumes:     # Optional — declares named volumes
  pgdata: ...

networks:    # Optional — declares custom networks
  backend: ...

configs:     # Optional — external configuration files
secrets:     # Optional — sensitive data management

Here is a complete, production-ready multi-service example with a Node.js web app, PostgreSQL database, and Redis cache:

# docker-compose.yml — Complete multi-service example
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgres://app:secret@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped
    volumes:
      - ./uploads:/app/uploads
    networks:
      - app-network

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    networks:
      - app-network

  cache:
    image: redis:7-alpine
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    restart: unless-stopped
    networks:
      - app-network

volumes:
  pgdata:

networks:
  app-network:
    driver: bridge

File Naming and Location

Docker Compose looks for files in this priority order:

  1. compose.yml (Compose Specification standard)
  2. compose.yaml
  3. docker-compose.yml (legacy, still widely used)
  4. docker-compose.yaml

You can also specify a file explicitly with docker compose -f custom.yml up. Multiple -f flags merge files, which is useful for environment-specific overrides: docker compose -f compose.yml -f compose.prod.yml up.

4. Service Configuration Deep Dive

Each entry under services: defines a container. Here are the most important service-level keys:

image vs build

Every service needs either image: (pull a pre-built image) or build: (build from a Dockerfile). You can use both — build to create the image and image to tag it:

services:
  web:
    build: .              # build from ./Dockerfile
    image: my-app:1.2.3   # tag the built image

ports

Map host ports to container ports. Always quote port mappings in YAML to prevent the parser from interpreting 80:80 as a base-60 number:

ports:
  - "3000:3000"           # host:container
  - "127.0.0.1:8080:80"  # bind to localhost only
  - "5432"                # random host port → container 5432

depends_on

Controls startup order. The basic form only waits for the container to start, not for the application to be ready. Use the extended form with healthchecks for reliable ordering:

# Basic (waits for container start only)
depends_on:
  - db
  - cache

# Extended (waits for healthy status)
depends_on:
  db:
    condition: service_healthy
  cache:
    condition: service_started

restart

PolicyBehaviorUse Case
noNever restartOne-off tasks, migrations
alwaysAlways restart, even on clean exitCritical infrastructure
unless-stoppedRestart unless manually stopped✅ Most production services
on-failureRestart only on non-zero exitWorkers, batch jobs

5. Environment Variables & Secrets

Environment variable management is one of the trickiest parts of Docker Compose. There are three mechanisms, and they have a specific priority order when they overlap:

# .env file (loaded automatically by Compose)
POSTGRES_USER=app
POSTGRES_PASSWORD=super-secret-password
POSTGRES_DB=myapp

# docker-compose.yml — three ways to use env vars:

# Method 1: env_file (load from file)
services:
  db:
    image: postgres:16-alpine
    env_file:
      - .env.db
      - .env.db.local   # overrides .env.db

# Method 2: environment (inline)
  web:
    image: my-app:latest
    environment:
      - NODE_ENV=production
      - API_KEY=${API_KEY}   # interpolate from shell/.env

# Method 3: .env auto-loading (Compose interpolation)
  cache:
    image: redis:7-alpine
    ports:
      - "${REDIS_PORT:-6379}:6379"   # default if not set

Override Priority (Highest → Lowest)

  1. docker compose run -e VAR=value — CLI override (highest)
  2. environment: key in compose file
  3. env_file: loaded files
  4. .env file in project root (Compose interpolation only)
  5. Host shell environment variables (lowest)

Important: The .env file in the project root is special — Compose reads it automatically for ${VARIABLE} interpolation inside docker-compose.yml. It does not automatically inject variables into containers. For that, you need env_file or environment.

Managing environment variables across multiple services and files gets complex fast. Use our Docker Env Mapper to visualize which variables go to which services, detect duplicates, and generate clean env_file configurations.

6. Volume Mounts & Permissions

Volumes are how containers persist data and share files with the host. Getting volume permissions right is the single most common source of Docker frustration. There are three volume types:

TypeSyntaxUse CaseSurvives down -v?
Bind mount./host/path:/container/pathDevelopment, config filesYes (on host)
Named volumevolume-name:/container/pathDatabase data, uploads❌ No
tmpfstmpfs: /tmpTemporary data, caches❌ No (RAM only)

The UID/GID Permission Problem

The #1 Docker volume issue: your container runs as user node (UID 1000), but the host files are owned by root (UID 0). The container gets "Permission denied" when trying to write. This happens because Linux file permissions are based on numeric UID/GID, not usernames:

# Problem: container runs as UID 1000, host files owned by root
# Result: "Permission denied" when writing to bind mount

# Fix 1: Match UIDs in Dockerfile
FROM node:20-alpine
RUN mkdir -p /app/uploads && chown -R node:node /app/uploads
USER node

# Fix 2: Set user in docker-compose.yml
services:
  web:
    image: my-app
    user: "1000:1000"
    volumes:
      - ./uploads:/app/uploads

# Fix 3: Fix host permissions before mounting
# On the host:
sudo chown -R 1000:1000 ./uploads
sudo chmod 755 ./uploads

The permission calculation for Docker volumes involves matching host UIDs with container UIDs, setting the right chmod mode, and configuring the Dockerfile correctly. Our Docker Volume Permissions Helper generates all of this for you — the correct --user flags, Dockerfile RUN chown commands, and compose volume configurations.

Need to calculate specific Linux permission modes? Use the Chmod Calculator for visual permission selection. To generate ownership change commands, use the Chown Command Generator.

Docker Desktop vs Linux Host

On Docker Desktop (macOS/Windows), bind mounts go through a file-sharing layer (gRPC-FUSE or VirtioFS). This introduces I/O overhead — especially for node_modules directories with thousands of files. Named volumes bypass this layer and are significantly faster. On native Linux, bind mounts have no overhead because Docker accesses the host filesystem directly.

Best practice: use bind mounts for source code (you need live editing), but named volumes for node_modules and dependency directories:

services:
  web:
    volumes:
      - .:/app                     # bind mount for source code
      - node_modules:/app/node_modules  # named volume for deps (fast)

volumes:
  node_modules:

7. Docker Compose Networking

Docker Compose automatically creates a default bridge network for each project. All services in the same Compose file can reach each other by service name:

# If your compose file defines:
services:
  web: ...
  db: ...
  cache: ...

# Then inside the 'web' container:
# - 'db' resolves to the database container's IP
# - 'cache' resolves to the Redis container's IP
# Example connection string: postgres://user:pass@db:5432/mydb

For more complex setups, define custom networks to isolate groups of services:

# Custom networks for service isolation
services:
  web:
    networks:
      - frontend
      - backend    # web can reach both networks

  api:
    networks:
      - backend    # api only on backend

  db:
    networks:
      - backend    # db only on backend (not exposed to frontend)

  nginx:
    networks:
      - frontend   # nginx only on frontend

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true   # no external internet access

Network Aliases and DNS

By default, a service is reachable by its service key name. You can add additional DNS names using aliases:

services:
  db:
    image: postgres:16-alpine
    networks:
      backend:
        aliases:
          - database
          - postgres
# Now 'db', 'database', and 'postgres' all resolve to this container

8. Building Images with Compose

Compose can build images from Dockerfiles as part of the up process. This is especially useful for development workflows where you're iterating on your application code:

# Multi-stage build with Compose
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
      target: production        # use specific stage
      args:
        - NODE_VERSION=20
        - BUILD_DATE=2026-06-21
      cache_from:
        - my-app:latest         # use previous image as cache layer

# Dockerfile (multi-stage)
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
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 package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Build Cache Optimization

Docker builds images in layers. Each instruction in your Dockerfile creates a layer. If a layer hasn't changed, Docker reuses the cached version. The order of your Dockerfile instructions directly affects build speed:

  • Copy dependency files first (package.json, requirements.txt), then install dependencies, then copy source code. This way, changing source code doesn't invalidate the dependency cache.
  • Use .dockerignore to exclude node_modules/, .git/, dist/, and other large directories from the build context.
  • Use --build flag: docker compose up -d --build forces a rebuild. Without it, Compose reuses existing images.

9. Essential CLI Commands

These are the Docker Compose commands you will use daily:

# Start all services (detached)
docker compose up -d

# Start specific services only
docker compose up -d web db

# Stop and remove containers, networks
docker compose down

# Stop and remove everything including volumes (⚠️ data loss)
docker compose down -v

# View real-time logs
docker compose logs -f
docker compose logs -f web       # specific service

# List running containers
docker compose ps

# Execute command in running container
docker compose exec web sh
docker compose exec db psql -U app -d myapp

# Restart a single service
docker compose restart web

# Rebuild images and restart
docker compose up -d --build

# Pull latest images
docker compose pull

# Validate compose file syntax
docker compose config --quiet

# Scale a service (multiple replicas)
docker compose up -d --scale worker=3

Command Cheat Sheet

CommandPurpose
docker compose up -dStart all services in background
docker compose downStop and remove containers + networks
docker compose down -v⚠️ Also remove named volumes (data loss)
docker compose logs -fFollow real-time logs from all services
docker compose psList containers with status and ports
docker compose exec web shOpen shell inside running container
docker compose up -d --buildRebuild images then restart
docker compose config --quietValidate compose file syntax
docker compose pullPull latest versions of all images

10. Healthchecks & Startup Order

depends_on without healthchecks only waits for the container to start, not for the application inside to be ready. A PostgreSQL container can be "started" but still initializing its database. Your web app connects, gets "connection refused", and crashes.

Healthchecks solve this by letting Compose wait for a container to report itself as healthy before starting dependent services:

# Healthcheck patterns for common services
services:
  postgres:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

  mysql:
    image: mysql:8
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 3

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

  web:
    depends_on:
      postgres:
        condition: service_healthy    # wait for healthy, not just started
      redis:
        condition: service_healthy

Healthcheck Parameters

ParameterDefaultPurpose
testCommand to run. Exit 0 = healthy, exit 1 = unhealthy
interval30sTime between health checks
timeout30sMax time for a single check to complete
retries3Consecutive failures before marking unhealthy
start_period0sGrace period — failures during this time don't count

11. Production Best Practices

Running Docker Compose in production on a single server is a legitimate deployment strategy for many applications. Follow these hardening practices:

# Production best practices
services:
  web:
    image: my-app:1.2.3           # pin exact version, never use :latest
    restart: unless-stopped        # auto-restart on crash
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M
    logging:
      driver: json-file
      options:
        max-size: "10m"           # rotate logs at 10MB
        max-file: "3"             # keep 3 rotated files
    read_only: true                # read-only root filesystem
    tmpfs:
      - /tmp                      # writable tmp in memory
    security_opt:
      - no-new-privileges:true    # prevent privilege escalation

Production Checklist

  • Pin image versionspostgres:16.3-alpine, never :latest
  • Set resource limits — prevent any container from consuming all host memory/CPU
  • Configure log rotation — without limits, Docker logs grow until they fill the disk
  • Use restart: unless-stopped — auto-recover from crashes and reboots
  • Enable read-only root filesystem — prevents attackers from modifying binaries
  • Use no-new-privileges — prevents privilege escalation inside containers
  • Separate secrets from config — use env_file with .gitignore
  • Back up named volumesdocker run --rm -v pgdata:/data -v $(pwd):/backup alpine tar czf /backup/pgdata.tar.gz /data

12. Validation & Troubleshooting

Docker Compose YAML errors are notoriously frustrating. A single wrong indentation, an unquoted port mapping, or a missing image: directive can cause silent failures. Here are the most common issues and how to debug them:

# Validate compose file (catches YAML and schema errors)
docker compose config --quiet
# No output = valid. Errors print to stderr.

# Check which compose file is being used
docker compose config --format json | head -5

# Debug container startup failures
docker compose logs web --tail 50
docker compose events

# Inspect a container's environment
docker compose exec web env

# Check port bindings
docker compose port web 3000

# View resource usage
docker compose top
docker stats

# Force recreate containers (even if config unchanged)
docker compose up -d --force-recreate

# Remove orphaned containers (from removed services)
docker compose up -d --remove-orphans

Common Errors and Fixes

ErrorCauseFix
yaml: line X: did not find expected keyWrong indentationCheck for mixed tabs/spaces. Use 2-space indent.
service "web" has neither an image nor a build contextMissing image: or build:Add one of them to the service definition.
port is already allocatedHost port conflictChange host port or stop conflicting process.
permission deniedVolume UID/GID mismatchFix with --user, Dockerfile chown, or host chown.
network X not foundMissing network declarationAdd networks: section at top level.
depends_on: service "db" is not healthyNo healthcheck or failing healthcheckAdd healthcheck to the dependent service.

For instant validation of your docker-compose.yml files without running Docker, use our Docker Compose Validator. It parses your YAML, checks for semantic errors (missing images, unquoted ports, invalid environment formats), and gives you a structural audit — entirely in your browser.

Frequently Asked Questions

What is the difference between Docker Compose v1 and v2?
Docker Compose v1 was a standalone Python binary (docker-compose with a hyphen). V2 is a Go plugin integrated into the Docker CLI (docker compose with a space). V2 is faster, supports BuildKit by default, and uses the Compose Specification. V1 reached end-of-life in June 2023.
What is the difference between env_file and environment?
The environment key defines variables inline in the YAML file. env_file loads them from an external file. If both define the same variable, environment wins. Use env_file for secrets you don't want in version control, and environment for non-sensitive defaults.
How do I fix "permission denied" with Docker volumes?
Permission errors happen when the container's UID doesn't match the host file ownership. Fix by: 1) Adding user: "1000:1000" in compose, 2) Running chown in your Dockerfile, 3) Fixing host permissions with sudo chown -R 1000:1000 ./data. Use the Docker Volume Permissions Helper to generate the correct commands.
Should I use the version key in docker-compose.yml?
No. The version: key is obsolete as of Compose v2.20+. The Compose Specification ignores it. Remove it from new files. Old tutorials with version: "3.8" are outdated.
What is the difference between bind mounts and named volumes?
Bind mounts map a specific host path to a container path. Named volumes are managed by Docker in /var/lib/docker/volumes/. Use bind mounts for development (live code editing) and named volumes for production data (databases), which have better performance on Docker Desktop.
How do containers communicate in Docker Compose?
Compose creates a default bridge network. Each service is reachable by its service name as a DNS hostname. The web service connects to db:5432 — Docker's DNS resolves db to the container's IP automatically.
How do I convert a docker run command to Compose?
Map each flag: -pports, -vvolumes, -eenvironment, --name → service key. Use the Docker Run to Compose Converter to automate this.
How do I make Compose wait for a database to be ready?
Add a healthcheck to the database service and use depends_on: db: condition: service_healthy. The basic depends_on without a condition only waits for the container to start, not for the application to be ready.