Docker Intermediate #3: docker compose Basics — web + db in One File
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:
- #1 Multi-stage builds and image slimming
- #2 Build cache — layer ordering
- #3 docker compose basics — web + db ← this post
- #4 compose deep dive — depends_on, healthcheck, profiles
- #5 Environment variables and secrets
- #6 Logging and debugging
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.
docker compose version
# Docker Compose version v2.30.xWhy Compose #
Porting what you’d do with docker run reveals the limits quickly:
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:latestThat’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:
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.
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:
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 key | Meaning |
|---|---|
services | The containers (you’ll fill this in most) |
volumes | Named volumes |
networks | Named networks |
secrets | Secrets (covered in #5) |
configs | Config 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.
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 theDockerfilein 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 asdocker run -p."127.0.0.1:5432:5432"to bind only to the host loopback.environment:— the-e KEY=valslot. 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.
docker network ls
# NAME DRIVER SCOPE
# myapp_default bridge localServices 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.
docker ps
# NAMES
# myapp-web-1
# myapp-pg-1
# myapp-redis-1The default project name is the directory name. Change it with -p:
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 #
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.yamldocker compose down # remove containers + networks
docker compose down -v # also remove volumes (data goes)
docker compose down --rmi local # also remove built imagesdocker compose ps # containers in this project only
docker compose ps -a # include stoppeddocker 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 minutesdocker compose exec web bash # bash in the running web container
docker compose run --rm web python manage.py migrate # one-shot in a new containerThe 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.
docker compose restart web # restart one service
docker compose stop # stop without removing the containers (different from down)
docker compose start # start what was stoppedup 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.restart—stopthenstart. Doesn’t pick up definition changes.
After editing compose.yaml, always up.
build: in depth
#
When a service builds its own image:
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 nameWhen 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::
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.
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 to0.0.0.0and write to stdout. - DB is up but web gets “connection refused” —
depends_ononly 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 downwiped DB data —-vremoves named volumes too. Default todownwithout-v.build:change isn’t reflected — useup --buildorup -d --build.
One full cycle #
A typical day on a new project:
# 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 --buildOnce 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.yamlwithservices/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. upreflects changes;startjust starts. After editingcompose.yaml, alwaysup.
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.