Contents

Docker Compose File Tutorial: Complete compose.yaml Syntax Guide

compose.yaml has four main top-level sections:

name: myapp          # Project name (optional; defaults to the folder name)

services:            # Containers to run
  ...

volumes:             # Named volumes
  ...

networks:            # Custom networks (optional; one is created automatically by default)
  ...

The sections below explain the most commonly used settings under services, followed by a complete example that you can run.

Services, project network, named volume, and host-mapped ports in a Compose project
A Compose project: service names are hostnames, and only services with ports configured can be reached from the host

image and build

Use an existing image:

services:
  redis:
    image: redis:8-alpine

Build from a Dockerfile:

services:
  app:
    build: .                     # Use the Dockerfile in the current folder

For more options:

services:
  app:
    build:
      context: ./backend
      dockerfile: Dockerfile.prod
      target: prod               # Target stage in a multi-stage build
      args:
        APP_VERSION: "1.4.2"
    image: myorg/app:1.4.2       # Image name after the build

When you specify both build and image, Compose names the build result with the image value. You can then run docker compose push.

ports

    ports:
      - "8080:80"              # Host:container
      - "127.0.0.1:5432:5432"  # Expose on localhost only
      - "9000-9002:9000-9002"  # Range

Add quotation marks. YAML interprets some number:number formats as base-60 numbers, which can cause strange errors.

expose only documents intended use; it does not publish a port. Services on the same network can already communicate with each other.

volumes

services:
  db:
    image: postgres:18
    volumes:
      - pgdata:/var/lib/postgresql                               # Named volume
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro       # Bind mount (relative path)

volumes:
  pgdata:
  • Paths starting with ./ or ../ are bind mounts relative to compose.yaml.

  • A name by itself is a named volume. You must declare it in the top-level volumes: section. Compose prefixes its actual name with the project name (for example, shop_pgdata).

  • To use a volume created outside Compose:

    volumes:
      pgdata:
        external: true

environment and env_file

    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
    env_file:
      - .env.app

${...} values come from a .env file or the current environment variables. See Environment variables and .env files.

depends_on and healthcheck: wait until the database is actually ready

depends_on controls startup order only: it ensures the db container starts first, but does not ensure that the database accepts connections. PostgreSQL takes a few seconds to start, and the application can fail if it tries to connect too soon. With healthcheck and condition: service_healthy, Compose waits for the health check to pass before starting the application.

A working example (compose.yaml):

name: shop

services:
  app:
    image: postgres:18
    command: ["psql", "-h", "db", "-U", "shop", "-c", "SELECT 'app connected' AS status;"]
    environment:
      PGPASSWORD: ${DB_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:18
    environment:
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
      POSTGRES_DB: shop
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U shop -d shop"]
      interval: 2s
      timeout: 3s
      retries: 20

  adminer:
    image: adminer
    ports:
      - "8081:8080"
    profiles: [tools]

volumes:
  pgdata:

app uses the PostgreSQL client to simulate an application that connects to the database at startup. Set DB_PASSWORD=change-me in .env.

Start db by itself first and watch its health status change:

$ docker compose up -d db
[+] up 3/3
 ✔ Network shop_default Created                                                                 0.0s
 ✔ Volume shop_pgdata   Created                                                                 0.0s
 ✔ Container shop-db-1  Started                                                                 0.3s
$ docker compose ps
NAME        IMAGE         COMMAND                  SERVICE   CREATED        STATUS                                     PORTS
shop-db-1   postgres:18   "docker-entrypoint.s…"   db        1 second ago   Up Less than a second (health: starting)   127.0.0.1:5432->5432/tcp

A few seconds later:

$ docker compose ps
NAME        IMAGE         COMMAND                  SERVICE   CREATED          STATUS                   PORTS
shop-db-1   postgres:18   "docker-entrypoint.s…"   db        10 seconds ago   Up 9 seconds (healthy)   127.0.0.1:5432->5432/tcp

The status changes from health: starting to healthy. Stop it, then start app from scratch:

$ docker compose up app
[+] up 3/3
 ✔ Network shop_default Created                                                                 0.0s
 ✔ Container shop-db-1  Created                                                                 0.0s
 ✔ Container shop-app-1 Created                                                                 0.1s
Attaching to app-1
Container shop-db-1 Waiting
Container shop-db-1 Healthy
app-1  |     status
app-1  | ---------------
app-1  |  app connected
app-1  | (1 row)
app-1  |
app-1 exited with code 0

w Enable Watch   d Detach

Compose starts db first, displays Waiting, and starts app only after it becomes Healthy. The first connection from app succeeds (app connected). Without condition: service_healthy, app tries to connect before the database is ready.

(The last line, w Enable Watch d Detach, shows the keyboard shortcuts available while Compose runs in the foreground.)

The pg_isready health check includes -h 127.0.0.1. Without it, the database is marked healthy during its initial setup, and the application then fails to connect. See the test results in PostgreSQL. MySQL has the same issue; see MySQL.

Values you can use for condition:

ValueMeaning
service_startedThe container has started (default)
service_healthyThe health check has passed
service_completed_successfullyThe service has finished running with exit code 0; useful for one-off tasks such as database migrations

You can also add restart: true under depends_on so that app restarts when db restarts.

restart

    restart: unless-stopped

Available values: no, always, on-failure, and unless-stopped (see Container lifecycle for their behavior).

command and entrypoint

Override the image's CMD / ENTRYPOINT:

    command: ["npm", "run", "dev"]
    entrypoint: ["/app/docker-entrypoint.sh"]

networks

By default, all services are on the same project-name_default network. To isolate services, you can define your own networks:

services:
  web:
    image: nginx:1.30-alpine
    networks: [frontend]
  app:
    build: .
    networks: [frontend, backend]
  db:
    image: postgres:18
    networks: [backend]

networks:
  frontend:
  backend:

web can connect to app, but not to db.

profiles: selectively start services

The adminer service in the example above has profiles: [tools]:

$ docker compose config --services
db
app
$ docker compose --profile tools config --services
db
app
adminer

Without a profile, adminer does not start. Add --profile tools to start it. This is useful for services you only start when needed, such as management tools and test mail servers.

Resource limits and other common settings

services:
  app:
    image: myorg/app:1.4.2
    container_name: app          # Fixed container name (cannot be scaled later)
    user: "1000:1000"
    working_dir: /app
    mem_limit: 512m
    cpus: 1.5
    init: true                   # Equivalent to docker run --init
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    extra_hosts:
      - "host.docker.internal:host-gateway"

compose.override.yaml: merge settings

Compose automatically merges compose.yaml and compose.override.yaml. The example folder also contains this file:

# compose.override.yaml
services:
  db:
    ports:
      - "127.0.0.1:5432:5432"

Use docker compose config to inspect the final merged configuration (note the ports at the end):

$ docker compose config db
name: shop
services:
  db:
    environment:
      POSTGRES_DB: shop
      POSTGRES_PASSWORD: change-me
      POSTGRES_USER: shop
    image: postgres:18
    networks:
      default: null
    volumes:
      - type: volume
        source: pgdata
        target: /var/lib/postgresql
        volume: {}
    healthcheck:
      test:
        - CMD-SHELL
        - pg_isready -h 127.0.0.1 -U shop -d shop
      timeout: 3s
      interval: 2s
      retries: 20
    ports:
      - mode: ingress
        host_ip: 127.0.0.1
        target: 5432
        published: "5432"
        protocol: tcp
networks:
  default:
    name: shop_default
volumes:
  pgdata:
    name: shop_pgdata

If you explicitly specify a single file with -f compose.yaml, the override is not merged and ports is absent:

$ docker compose -f compose.yaml config db
name: shop
services:
  db:
    environment:
      POSTGRES_DB: shop
      POSTGRES_PASSWORD: change-me
      POSTGRES_USER: shop
    image: postgres:18
    networks:
      default: null
    volumes:
      - type: volume
        source: pgdata
        target: /var/lib/postgresql
        volume: {}
    healthcheck:
      test:
        - CMD-SHELL
        - pg_isready -h 127.0.0.1 -U shop -d shop
      timeout: 3s
      interval: 2s
      retries: 20
networks:
  default:
    name: shop_default
volumes:
  pgdata:
    name: shop_pgdata

docker compose config also expands shorthand into the full format (for example, pgdata:/var/lib/postgresql becomes type: volume, source, and target) and substitutes ${DB_PASSWORD}. If a setting does not seem to take effect, use this command first to check what Compose actually read.

A common division of responsibilities:

  • compose.yaml: settings shared by all environments.
  • compose.override.yaml: development-only settings (mount source code, expose debugging ports). Applied automatically when you run docker compose up locally.
  • compose.prod.yaml: production settings, specified with docker compose -f compose.yaml -f compose.prod.yaml up -d.

Recap

  • image uses an existing image; build builds from a Dockerfile.
  • Only depends_on with condition: service_healthy waits until a service is actually ready.
  • profiles let you start services such as management tools only when needed.
  • compose.override.yaml is merged automatically. Use docker compose config to inspect the final configuration.

Exercises

Exercise 1: Add restart: unless-stopped to the db service in this chapter and limit its memory to 512 MB.

Answer
  db:
    image: postgres:18
    restart: unless-stopped
    mem_limit: 512m

Run docker compose up -d to apply the changes.

Exercise 2: adminer has profiles: [tools]. How do you start it too?

Answer
docker compose --profile tools up -d

FAQ

Why doesn't depends_on wait for the database to be ready?

Writing only depends_on: [db] guarantees startup order. To wait until the database accepts connections, add a healthcheck and condition: service_healthy. See this chapter and [PostgreSQL](/docker/postgresql/) for the health check syntax.

What happens if YAML indentation is wrong?

docker compose config reports an error, such as did not find expected key. See [Common error messages](/docker/errors/). Use spaces only, not tabs.

Do I still need to write version: '3.8'?

No. The Compose Specification no longer uses the version field. Newer versions ignore it and display a warning.

Updated October 14, 2026