Docker & Containers — Level by Level›04 · Docker Compose basics

Lesson 04 of 13 · Level 2 — Docker Compose

Docker Compose basics

Describe a whole multi-container app in one file and start it with one command. Learn the compose.yaml structure, how services find each other, environment variables and .env files, and the day-to-day docker compose commands.

Beginner → Practitioner
Key wordsdocker composecompose.yamlservicesnetworksvolumesenvironment.envdocker compose updocker-compose v1
compose.yaml one file, one project docker compose up Browser localhost:8080 Project network (service names are DNS names) web nginx ports: 8080:80 api your image healthcheck db postgres healthcheck volume db-data survives down secret db_password /run/secrets/… http://api:8000 db:5432 creates everything depends_on: condition: service_healthy starts them in order
One compose.yaml creates the network, volumes and containers; services reach each other by name.

Why Compose

Running a three-service app with plain docker run means three long commands, a network, a volume, and remembering the order. Docker Compose puts all of it in one declarative file you can keep in Git:

$ docker compose up -d      # create and start everything
$ docker compose down       # stop and remove it again

docker run is ordering each dish separately and telling the waiter which table, in which order, every single time. A Compose file is the set menu written on a card: hand it over once and the whole meal arrives, in the right order, at the right table.

docker compose, not docker-compose

The original docker-compose (v1, written in Python) is no longer maintained. Today Compose is a Docker CLI plugin, run as docker compose (with a space). It's installed with Docker Engine from Docker's packages. The top-level version: key in old files is obsolete; current Compose ignores it and prints a warning.

A first compose.yaml

A web front end, an API built from local source, and PostgreSQL:

services:
  web:
    image: nginx:1.27
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api

  api:
    build: ./api                 # build from ./api/Dockerfile
    image: shop/api:dev          # tag the built image with this name
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/shop
    depends_on:
      - db

  db:
    image: postgres:${POSTGRES_VERSION:-16}
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: shop
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:

And a .env file next to it (git-ignored):

DB_PASSWORD=devonly-change-me
POSTGRES_VERSION=16

What Compose does with this:

  • Creates a network <project>_default. The project name defaults to the folder name.
  • Creates the volume <project>_db-data.
  • Builds api, pulls nginx and postgres, and starts the containers.
  • Every service is reachable by its service name: the API connects to db:5432, nginx proxies to http://api:8000.
  • Only web publishes a port. The database is not reachable from outside the host.

Run it

$ docker compose up -d --build
[+] Running 5/5
 ✔ Network shop_default   Created
 ✔ Volume "shop_db-data"  Created
 ✔ Container shop-db-1    Started
 ✔ Container shop-api-1   Started
 ✔ Container shop-web-1   Started
$ docker compose ps
NAME         IMAGE          SERVICE   STATUS         PORTS
shop-api-1   shop/api:dev   api       Up 5 seconds   8000/tcp
shop-db-1    postgres:16    db        Up 6 seconds   5432/tcp
shop-web-1   nginx:1.27     web       Up 5 seconds   0.0.0.0:8080->80/tcp
$ docker compose logs -f api
$ docker compose exec db psql -U app -d shop -c '\dt'

Change and re-apply

Compose is declarative: edit the file and run up -d again. It compares what you asked for with what's running and recreates only the services that changed.

$ sed -i 's/nginx:1.27/nginx:1.27-alpine/' compose.yaml
$ docker compose up -d
 ✔ Container shop-db-1   Running
 ✔ Container shop-api-1  Running
 ✔ Container shop-web-1  Started      # only web was recreated

docker compose restart does not pick up file changes; it restarts the existing containers as they are.

Environment variables, two different things

This trips people up, so be precise:

Where What it does
${VAR} in compose.yaml Substitution when Compose reads the file, from your shell or the .env file
environment: Variables set inside the container
env_file: [ api.env ] Load container variables from a file

Run docker compose config to see the final file with every variable filled in. It's the fastest way to debug "why is this value empty?". Be careful where you paste its output: it contains your secrets.

Try it: bring up the stack

  1. Create the folder layout: compose.yaml, .env, nginx.conf (proxy / to http://api:8000) and an api/ folder with any small web app and its Dockerfile from lesson 2.
  2. docker compose up -d --build, then curl localhost:8080.
  3. docker compose exec api getent hosts db shows service-name DNS at work.
  4. docker compose down, then up -d again: the database data is still there. Now try down -v and see it disappear.

Going deeper: project names and multiple copies

Two checkouts of the same repo in folders with the same name collide, because the project name (and so the container, network and volume names) comes from the folder name. Set it explicitly with name: shop at the top of compose.yaml, or -p shop-feature-x on the command line, to run several isolated copies of one stack on the same host, for example one per pull request on a CI runner.

Recap

  • One compose.yaml describes services, networks and volumes. Use docker compose (v2).
  • Compose creates a project network: services talk by service name. Publish only the entry point.
  • Edit the file, then docker compose up -d. It recreates only what changed.
  • ${VAR} = substitution from shell/.env; environment: = variables in the container. Check with docker compose config.
  • down keeps volumes, down -v deletes them.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.