TechEarl

Docker Compose: Getting Started with docker-compose.yml

A working docker-compose.yml that runs a web app and a database together, the commands you need (up, down, logs, exec), and the V1-to-V2 change that breaks a lot of older tutorials.

Ishan KarunaratneIshan Karunaratne⏱️ 8 min readUpdated
Share thisCopied

docker run is fine for one container. Two containers that need to find each other (a web app talking to a database) is where it gets awkward — you start writing shell scripts to orchestrate the order, the networks, the volumes, the rebuilds. Docker Compose is the tool that replaces those scripts with a single declarative file, docker-compose.yml, plus a small set of commands (up, down, logs, exec) that act on the whole stack at once.

This article is the working starting point: the file, what each section does, the commands you use daily, the V1-to-V2 change that breaks a lot of older tutorials, and the pitfalls that catch people the first time.

How does Docker Compose work?

You write a docker-compose.yml file describing one or more services (each service becomes a container), plus optional volumes (for data persistence) and networks (Compose creates one by default). Then docker compose up -d starts the whole stack, docker compose down stops it, and docker compose logs -f tails the output. A two-service web + db stack fits in 20 lines:

yaml
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    depends_on:
      - db
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: change-me
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:

Save that as docker-compose.yml, run docker compose up -d in the same directory, and you have an nginx server on port 8080 and a Postgres running alongside it with persistent storage. The rest of this article is what each piece does.

Jump to:

What changed in Compose V2

Compose V2 is the only supported version of Docker Compose in 2026. The standalone docker-compose (with a hyphen) binary was Python-based, has been deprecated for years, and was removed from official Docker packages mid-2024. The replacement is docker compose (with a space) — a Go-based CLI plugin that ships with Docker Engine.

In practice:

  • Command: docker compose up, not docker-compose up. Everything else is the same.
  • File name: docker-compose.yml (or compose.yaml) — unchanged.
  • version: field at the top: no longer required and warned-about. Drop it from new files. Old files with version: "3.8" still work but you can safely delete the line.
  • Built-in: if docker compose version does not work, your Docker install is missing the plugin. On Ubuntu: sudo apt install docker-compose-plugin. On Docker Desktop: it ships built-in.

The Compose file format itself is largely the same as V1; most stack files from 2020-2022 work with no changes once you drop the version: line.

The shape of docker-compose.yml

yaml
services:        # one or more containers
  service-name:
    # ... container config ...

volumes:         # named volumes (optional)
  volume-name:

networks:        # custom networks (optional)
  network-name:

Three top-level keys: services, volumes, networks. Of those, only services is required. Compose auto-creates a default network for the stack so containers can find each other by service name without you defining anything.

services — the heart of the file

Each service is a container. The fields under it are the Compose equivalents of docker run flags.

yaml
services:
  web:
    image: nginx:alpine            # use an existing image (alternative: build)
    container_name: my-web         # explicit name (optional; default is project_service_1)
    ports:
      - "8080:80"                  # -p 8080:80
      - "443:443"
    environment:
      NODE_ENV: production         # -e NODE_ENV=production
      DATABASE_URL: postgres://...
    env_file:
      - .env                       # --env-file .env
    volumes:
      - ./html:/usr/share/nginx/html  # bind mount (host path : container path)
      - app-data:/data                # named volume
    networks:
      - app-net
    depends_on:
      - db                         # start db before web
    restart: unless-stopped        # --restart unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3

  app:
    build:                         # build an image from a Dockerfile
      context: ./app               # path to the Dockerfile + build context
      dockerfile: Dockerfile       # default; can omit
    image: my-app:latest           # tag the built image

Two ways to get an image: image: pulls an existing one, build: builds one from a Dockerfile. You can use both at once — build produces an image and tags it with whatever image: says, which is the common pattern when you want to push the built image to a registry.

depends_on controls start order but not readiness. Compose starts db before web, but web boots before Postgres has finished initializing. For real "wait until the DB is ready" semantics, combine depends_on with a healthcheck:

yaml
  web:
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

Without the condition: service_healthy, depends_on is just order-of-start. Full breakdown in Docker Restart Policies and Health Checks.

volumes — persistent storage

yaml
services:
  db:
    image: postgres:16
    volumes:
      - db-data:/var/lib/postgresql/data    # named volume — survives container removal
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro   # bind mount, read-only

volumes:
  db-data:

The top-level volumes: declares named volumes that Compose manages. Without that block, named volumes referenced in a service still get created, but Compose names them with the project prefix (projectname_db-data); declaring them at the top level keeps things explicit.

Why named volumes for databases: a container's writable layer disappears when you remove the container. docker compose down removes the containers. Without a volume, all your database tables are gone with the next down/up cycle. The named volume persists across container removal. Full breakdown in Docker Volumes vs Bind Mounts.

docker compose down -v removes the named volumes too. That is the data-loss command; do not run it on a stack with state you care about.

networks — container DNS

Compose auto-creates one bridge network for the stack and attaches every service to it. On that network, services reach each other by their service name:

yaml
services:
  web:
    image: my-web
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/app
  db:
    image: postgres:16

From inside the web container, db resolves to the db container's IP. You do not publish ports for container-to-container traffic; ports: is only for traffic from the host or outside world.

You can declare additional networks if you want isolation (e.g., a frontend net and a backend net so the web service can reach both but the db sits only on the backend net):

yaml
services:
  web:
    networks: [frontend, backend]
  db:
    networks: [backend]

networks:
  frontend:
  backend:

Full picture in Docker Networking Basics.

Daily commands

bash
# Start the stack in the current directory
docker compose up -d

# Stop and remove containers + networks (volumes survive)
docker compose down

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

# Tail logs from all services
docker compose logs -f

# Tail logs from one service
docker compose logs -f web

# Shell into a service
docker compose exec web bash
docker compose exec db psql -U postgres

# Restart one service
docker compose restart web

# Rebuild images (after Dockerfile changes)
docker compose build
docker compose up -d --build

# Pull image updates and recreate containers
docker compose pull
docker compose up -d

# Run a one-off command in the service's image
docker compose run --rm web npm test

# Show the merged, validated config
docker compose config

# Stack-scoped container list
docker compose ps

Two notes:

  • All Compose commands act on the file in the current directory. Either run them from the directory containing docker-compose.yml, or pass -f path/to/file.yml.
  • The project name is the directory name by default. That is what Compose prefixes container, volume, and network names with. Override with -p projectname or by setting COMPOSE_PROJECT_NAME in an .env file.

A working example: Nginx + Postgres

yaml
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

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

volumes:
  db-data:

Start it:

bash
docker compose up -d
docker compose ps
docker compose logs -f web
  • web is reachable at http://localhost:8080. Drop index.html files into ./html on the host and they appear immediately (read-only bind mount).
  • db is not published to the host. Other containers on this stack reach it as db:5432. To connect from the host, add ports: ["5432:5432"] to the db service.
  • Stop the stack with docker compose down. The db-data volume survives. Bring it back up with docker compose up -d and Postgres still has your data.

Common pitfalls

  • Using docker-compose (hyphen) and getting command not found. Compose V1 is gone. Use docker compose (space). If docker compose itself is missing, install the docker-compose-plugin package.
  • version: "3.x" at the top of the file. No longer needed and warned-about. Delete the line.
  • depends_on without service_healthy — start order, not readiness. Your app boots before the DB is ready and fails to connect. Add a healthcheck.
  • Forgetting the named volume on the database. First docker compose down removes the container, the writable layer goes with it, and all your tables are gone. Always pin database paths to a named volume.
  • Publishing the database port unnecessarily. Compose's default network already lets web reach db. Only publish if you want to connect from the host (psql, DBeaver, your local app).
  • YAML indentation. Two spaces, not tabs. The most common "Compose says my file is invalid" cause.
  • Env vars from .env are not in the container. The .env file in the project directory feeds variable substitution in docker-compose.yml (e.g., ${POSTGRES_PASSWORD}), not the container's environment. To pass env vars into the container, use environment: or env_file: inside a service block.

What to do next

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsDockerDocker Composedocker-compose.ymlContainersDevOps

Found this useful? Pass it on.

Copied
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

d

docker system prune: Free Disk Space Used by Docker

Docker fills up your disk. The prune commands clean it: docker system prune for the everyday sweep, docker system prune -a --volumes for the nuclear option, docker builder prune for the BuildKit cache, plus the per-resource versions.