Docker Intermediate #3: docker compose Basics — web + db in One File

8 min read

Up to here we’ve worked with one container at a time. But real apps are usually web + db, or web + db + cache + worker — bundles of several containers. The tool that defines this in one file and runs it with one command is Docker Compose.

This post in the Docker Intermediate series:

docker-compose vs. docker compose #

A confusing point upfront — there are two spellings.

  • docker-compose (hyphen) — old v1, a separate Python binary. Deprecated.
  • docker compose (space) — current v2, Go-based and integrated into Docker. The standard.

This post sticks to docker compose (v2). Docker Desktop ships it; on Linux there’s a docker-compose-plugin package.

Verify install
docker compose version
# Docker Compose version v2.30.x

Why Compose #

Porting what you’d do with docker run reveals the limits quickly:

Standing up web + db with run
docker network create myapp-net

docker volume create pgdata

docker run -d --name pg \
  --network myapp-net \
  -v pgdata:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret \
  postgres:16

docker run -d --name web \
  --network myapp-net \
  -p 8000:8000 \
  -e DB_HOST=pg \
  -e DB_PASSWORD=secret \
  myapp:latest

That’s the minimum. Add anything and it grows into a shell script — and even then, any change forces you to start over, and onboarding a teammate means a long README.

The same thing in Compose:

compose.yaml
services:
  pg:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data

  web:
    image: myapp:latest
    ports:
      - "8000:8000"
    environment:
      DB_HOST: pg
      DB_PASSWORD: secret
    depends_on:
      - pg

volumes:
  pgdata:

docker compose up, one command for everything. Edit and the same command rebuilds only what changed. Onboarding becomes “clone the repo, run docker compose up.”

File name and location #

The standard file name is compose.yaml (or compose.yml). The older docker-compose.yml still works, but new projects should use compose.yaml.

Default search order
compose.yaml          # 1
compose.yml           # 2
docker-compose.yaml   # 3
docker-compose.yml    # 4 (legacy convention)

Run docker compose up from the directory holding the file — without -f, it searches in the order above. To point at a different path:

Specify the file
docker compose -f infra/compose.yaml up
docker compose -f compose.yaml -f compose.dev.yaml up   # multiple (override)

compose.yaml’s big picture #

The top-level keys:

Top-level keyMeaning
servicesThe containers (you’ll fill this in most)
volumesNamed volumes
networksNamed networks
secretsSecrets (covered in #5)
configsConfig files mounted into containers

The version: "3.8" line you see in older files isn’t used anymore. Compose v2 ignores it. Cleaner to omit it in new files.

Real example — Django + Postgres + Redis #

A more realistic example. The kind of setup you’d see in the Django in Practice series.

compose.yaml
services:
  web:
    build: .
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - ./:/app
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://app:secret@pg:5432/app
      REDIS_URL: redis://redis:6379/0
      DEBUG: "1"
    depends_on:
      - pg
      - redis

  pg:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "127.0.0.1:5432:5432"   # only if you want to hit the DB from a host client

  redis:
    image: redis:7-alpine
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:

Unpacking each piece:

  • build: . — build using the Dockerfile in the current directory. For when you produce the image yourself.
  • image: postgres:16 — pull and use a published image.
  • command: — override the image’s default CMD.
  • volumes: — bind mounts (./:/app) and named volumes (pgdata:/var/...) sit side by side. Path before : → bind; bare name → named.
  • ports: — same form as docker run -p. "127.0.0.1:5432:5432" to bind only to the host loopback.
  • environment: — the -e KEY=val slot. Mapping or list form both work.
  • depends_on: — start order (deeper in #4).

What Compose handles for you #

The fiddly bits you used to do with docker run are now automatic.

Automatic network #

The first compose up creates a default network named after the project and attaches every service to it.

Network created automatically
docker network ls
# NAME                  DRIVER    SCOPE
# myapp_default         bridge    local

Services on the same network can address each other by service name. In the example above, web’s DATABASE_URL: postgres://app:secret@pg:5432/app uses pg as the host — and it just works. (Same principle as the user-defined bridge + DNS from Basics #4.)

Project namespacing #

Compose prefixes every resource with the project name.

Container names
docker ps
# NAMES
# myapp-web-1
# myapp-pg-1
# myapp-redis-1

The default project name is the directory name. Change it with -p:

Pick a project name
docker compose -p myproj up
# myproj-web-1, myproj-pg-1 ...

You can run the same compose file twice on one machine under different names. Useful for running test and dev environments side by side.

Day-to-day commands #

up — start things
docker compose up                # foreground + all logs in one place
docker compose up -d             # background (detached)
docker compose up --build        # rebuild first (after a code change)
docker compose up web            # only one service
docker compose up --remove-orphans   # also clean up services dropped from compose.yaml
down — stop
docker compose down              # remove containers + networks
docker compose down -v           # also remove volumes (data goes)
docker compose down --rmi local  # also remove built images
ps — status
docker compose ps                # containers in this project only
docker compose ps -a             # include stopped
logs
docker compose logs              # logs for every service in one stream
docker compose logs -f           # follow
docker compose logs -f web       # only one service
docker compose logs --since 10m  # last 10 minutes
exec / run — getting inside
docker compose exec web bash     # bash in the running web container
docker compose run --rm web python manage.py migrate   # one-shot in a new container

The difference between exec and run mirrors docker exec vs docker run from Basics #3. Use run --rm for one-shot tasks like migrations or seeding; use exec for debugging an already-running container.

restart / stop / start
docker compose restart web       # restart one service
docker compose stop              # stop without removing the containers (different from down)
docker compose start             # start what was stopped

up vs start vs restart #

These three look similar but mean different things.

  • up — create containers (if missing) or recreate them to reflect changes (if the definition changed).
  • start — start existing, stopped containers. Doesn’t pick up definition changes.
  • restartstop then start. Doesn’t pick up definition changes.

After editing compose.yaml, always up.

build: in depth #

When a service builds its own image:

build options
services:
  web:
    build:
      context: .              # build context (where the Dockerfile lives)
      dockerfile: Dockerfile.dev   # use a non-default file
      args:
        APP_VERSION: "1.0.0"
      target: dev             # specific stage in a multi-stage build
      cache_from:
        - myapp:cache
    image: myapp:dev          # tag the built result with this name

When image: and build: appear together, the build result gets that name. Then docker compose push can ship it to a registry.

volumes: summarized #

You’ll see three forms in service-level volumes::

volumes shapes
services:
  web:
    volumes:
      - ./:/app                          # bind (relative)
      - /Users/me/data:/app/data         # bind (absolute)
      - pgdata:/var/lib/postgresql/data  # named (defined in top-level volumes)
      - logs:/app/logs:ro                # named, read-only
      - type: bind
        source: ./config
        target: /etc/myapp
        read_only: true                  # long form

volumes:
  pgdata:
  logs:

Short form covers daily needs. Reach for the long form when you need read-only, permissions, or extra options.

networks: — user-defined #

Beyond the default network you can define your own. Useful when only some services in a project should share a network.

Multiple networks
services:
  web:
    networks:
      - frontend
      - backend
  pg:
    networks:
      - backend
  nginx:
    networks:
      - frontend

networks:
  frontend:
  backend:

pg lives only on backend, so nginx can’t reach it directly — useful for security isolation. For typical apps, the default network alone is fine.

Common pitfalls #

  • No logs visible — the app is bound to 127.0.0.1, or it’s writing to a file. Bind to 0.0.0.0 and write to stdout.
  • DB is up but web gets “connection refused”depends_on only orders container starts. It doesn’t check whether the DB is ready. (Solved with healthcheck in #4.)
  • Bind-mounted code isn’t visible inside the container — Docker Desktop’s File Sharing might not include that directory. Check Settings → Resources → File Sharing.
  • compose down wiped DB data-v removes named volumes too. Default to down without -v.
  • build: change isn’t reflected — use up --build or up -d --build.

One full cycle #

A typical day on a new project:

Dev flow
# first time
docker compose up -d --build

# you change code (auto-applies via bind mount; auto-restarts on a dev server)
docker compose logs -f web

# migrations
docker compose run --rm web python manage.py migrate

# drop into the DB shell
docker compose exec pg psql -U app

# end of day
docker compose down

# fresh start, including DB data
docker compose down -v
docker compose up -d --build

Once that flow is in your hands, an entire project’s infrastructure boils down to one file and a handful of commands.

Wrap-up #

The picture from this post:

  • docker compose (v2) is the standard; docker-compose (v1) is deprecated.
  • One compose.yaml with services / volumes / networks — start it all with one command.
  • Services address each other by service name as DNS (default network = user-defined bridge).
  • build: + image: keeps build and tag together.
  • Daily commands: up -d, down, logs -f, exec, run --rm.
  • up reflects changes; start just starts. After editing compose.yaml, always up.

In the next post (#4 compose deep dive — depends_on, healthcheck, profiles) we layer operational tools onto this skeleton: healthcheck to ask “is the DB actually ready?”, depends_on with condition for meaningful startup order, and profiles to fork dev / test / prod inside a single file.

X