Understanding Docker Compose: services, volumes, networks
compose.yaml explained: services, volumes, networks and variables – the Docker vocabulary every app recipe in the Serverküche builds on.
Table of contents
In the Docker tutorial you installed the Compose
plugin – but we didn’t explain it yet. We’re catching up on that now, because almost
every app recipe here describes an application as a compose.yaml. Anyone who
reads this file like a shopping list can adapt every following tutorial instead of
just copying it.
What are we building?
We build a small stack step by step and, along the way, get to know the five
building blocks that make up practically every compose.yaml: services (the
containers), ports (reachability from outside), volumes (persistent data),
networks (containers talking to each other) and environment variables
(configuration). By the end you’ll understand why your data survives a down
command – and when it doesn’t.
Tested with Docker Compose v5.3 (the docker compose plugin without a hyphen –
not the old docker-compose v1 with a hyphen).
Prerequisites
- A hardened server with Docker installed and the Compose plugin
- The user is in the
dockergroup (then you don’t needsudobeforedocker)
Step by step
Step 1: The first compose.yaml
A compose.yaml describes declaratively which containers should run – you say
what you want, not how. Create a project folder; the folder name later becomes
the prefix of all containers:
mkdir -p ~/compose-demo/site && cd ~/compose-demoCreate a small HTML page that we’ll serve in a moment:
echo '<h1>Hallo aus der Serverküche</h1>' > site/index.htmlAnd now the central file compose.yaml:
services:
web:
image: nginx:1.31
ports:
- "8080:80"
volumes:
- ./site:/usr/share/nginx/html:ro
restart: unless-stoppedLine by line:
services:– the top level. Every entry below it is a container.web:– a freely chosen service name. Remember it, it later also becomes the hostname on the internal network (step 3).image: nginx:1.31– the container image with a fixed tag. Never uselatest:latestchanges under you and makes errors unreproducible.ports: - "8080:80"– formatHOST:CONTAINER. Port 80 in the container is mapped to 8080 on the server. The server is always on the left.volumes: - ./site:...:ro– the localsitefolder is mounted into the web root,:ro= read-only. More on this in step 2.restart: unless-stopped– the container restarts automatically after a reboot or crash, unless you stopped it yourself. The sensible default for server services. The alternatives:no(never automatically – the default),always(restarts even after a manual stop, rarely wanted) andon-failure(only after a crash with an error code). For the vast majority of services,unless-stoppedis exactly right.
Start the stack:
docker compose up -d-d means detached (in the background). The first time, Docker downloads the
image; after that you see at the end:
Network compose-demo_default Created
Container compose-demo-web-1 StartedCheck the status:
docker compose psNAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
compose-demo-web-1 nginx:1.31 "/docker-entrypoint.…" web 10 seconds ago Up 9 seconds 0.0.0.0:8080->80/tcp, [::]:8080->80/tcpThe container is called compose-demo-web-1 – project folder + service + number.
Test:
curl localhost:8080<h1>Hallo aus der Serverküche</h1>Works. You see a service’s logs with docker compose logs web (or -f to follow).
Step 2: Volumes – where your data really lives
Containers are ephemeral: if you delete a container, everything written inside the container is gone. So that data survives this, there are two kinds of volumes:
- Bind mount (
./site:/usr/share/nginx/html): a folder from your server is mounted into the container. Ideal for config files you edit yourself. - Named volume (
webdata:/var/lib/...): storage managed by Docker. Ideal for database data – performant and cleanly separated from the host.
In step 1 we used a bind mount. For database-like services it looks like this –
change compose.yaml as a test:
services:
web:
image: nginx:1.31
volumes:
- webdata:/usr/share/nginx/html
volumes:
webdata:Named volumes must additionally be declared at the top level under volumes:.
After docker compose up -d the volume appears:
docker volume lsDRIVER VOLUME NAME
local compose-demo_webdataNow comes the crucial point:
docker compose down
docker volume ls Container compose-demo-web-1 Removed
Network compose-demo_default Removed
DRIVER VOLUME NAME
local compose-demo_webdatadocker compose down removes the container and network – the volume stays. This
is exactly why your database contents survive an update. Remember the counterpart:
down -v deletes data
docker compose down -v also deletes the named volumes – i.e. all the stack’s
persistent data. Never type the -v out of reflex. For a plain restart,
docker compose down (without -v) is enough.Step 3: Networks – containers talking to each other
Compose automatically creates one network per project (seen above:
compose-demo_default). All services in it reach each other via their service
name as the hostname – no fiddling with IPs. That’s the reason why, in app
tutorials, the application simply reaches its database under db.
As proof, a second service that talks to the first:
services:
web:
image: nginx:1.31
volumes:
- ./site:/usr/share/nginx/html:ro
ping:
image: curlimages/curl:8.21.0
depends_on:
- web
command: ["curl", "-s", "http://web"]depends_on: - web– Compose startswebbeforeping. (Careful: this only waits for the start, not for “fully booted” – that’s what healthchecks are for, step 4.)command:– overrides the image’s default command.pingcallshttp://web–webis the service name from the same file.
Run only the ping service once:
docker compose run --rm ping<h1>Hallo aus der Serverküche</h1>ping reached web purely by name – without any port mapping. Remember: you only
need ports: to make a service reachable from outside (the internet). Containers
talk to each other over the internal network – that’s why we later deliberately run
databases without ports:.
So far, every network lives inside a Compose project. But sometimes containers from different projects should talk to each other – the classic example is a reverse proxy sitting in front of many independent app stacks. For that there’s the external network: one you create once by hand and that several Compose projects then share:
docker network create proxyIn the compose.yaml you then don’t create it again, but reference the already
existing network with external: true:
services:
web:
image: nginx:1.31
networks:
- proxy
networks:
proxy:
external: trueexternal: true tells Compose: “This network already exists – do not create it
and do not delete it on down.” If the network is missing, up aborts with
network proxy declared as external, but could not be found – then you forgot the
docker network create. This very pattern – a shared proxy network plus
external: true – is the basis of the Traefik
tutorial, with which every app later gets its
domain and its HTTPS.
Step 4: Configuration – environment variables, .env and healthchecks
Almost every application is configured via environment variables. Two ways:
services:
app:
image: beispiel/app:1.0
environment:
- TZ=Europe/Berlin
- APP_PORT=3000Secrets (passwords, tokens) do not belong in the compose.yaml, but in a .env
file in the same folder. Compose reads it automatically:
echo "DB_PASSWORD=EIN_LANGES_ZUFALLSPASSWORT" > .envservices:
db:
image: postgres:18
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}Never push `.env` to a backup repo
.env contains plaintext secrets. Add it to a .gitignore if you version your
Compose files, and back it up separately (encrypted).This is how you check whether Compose understands your file and inserts the
.env values correctly – without starting anything:
docker compose configconfig resolves all variables and prints the finished, normalized configuration:
name: compose-demo
services:
db:
environment:
POSTGRES_PASSWORD: EIN_LANGES_ZUFALLSPASSWORT
image: postgres:18
networks:
default: nullIf your actual value appears there instead of ${DB_PASSWORD}, the .env is
working. If you only need a quick syntax check without the whole output, use
docker compose config --quiet – if nothing comes back (exit code 0), the file is
valid. That’s also your first move for YAML errors (see below).
A healthcheck tells Docker when a service is really ready – the basis for dependent services only starting then:
services:
db:
image: postgres:18
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
app:
image: beispiel/app:1.0
depends_on:
db:
condition: service_healthyWith condition: service_healthy, app only starts once the healthcheck of db is
green – the most common “why won’t my app connect to the database?” disappears with
it. The four healthcheck fields mean: test is the command that runs in the
container (exit code 0 = healthy), interval the spacing between checks,
timeout how long a check may take, and retries how many consecutive
failures are needed before the container counts as unhealthy. The current state is
shown by the STATUS column of docker compose ps as (healthy) or (unhealthy).
Step 5: The operations toolbox
You need these commands daily – always run them in the project folder:
docker compose up -d # start / apply changes
docker compose ps # status of the services
docker compose logs -f web # follow logs live (Ctrl+C only ends the viewing)
docker compose exec web sh # shell in the running container
docker compose restart web # restart a single service
docker compose stop # halt without removing container/network
docker compose pull # fetch new image versions
docker compose down # stop and remove the stack (volumes stay)Almost all commands can be restricted to one service by appending its name
(docker compose logs -f web, docker compose restart web) – without a name they
apply to the whole stack. The difference between stop and down: stop only halts
the containers (start continues), down removes them along with the network (the
named volumes stay in both cases).
An update almost always follows the same pattern: bump the tag in the compose.yaml
→ docker compose pull → docker compose up -d. Compose only replaces the
containers whose image has changed.
Step 6: All together – a realistic app stack
This is the pattern you’ll encounter again and again in the app tutorials: an
application plus its database. This file bundles everything from steps 1–4 – read it
once in full, and you’ll have understood 90% of every later compose.yaml:
services:
app:
image: beispiel/app:1.4 # fixed version, no latest
ports:
- "8080:3000" # only the app is reachable from outside
environment:
- TZ=Europe/Berlin
- DATABASE_URL=postgres://app:${DB_PASSWORD}@db:5432/app
volumes:
- appdata:/data # persistent app data (named volume)
depends_on:
db:
condition: service_healthy # starts only once db is ready
restart: unless-stopped
db:
image: postgres:18
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=app
volumes:
- dbdata:/var/lib/postgresql/data # the actual database files
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# no ports: – the database is reachable ONLY internally via the name "db"
volumes:
appdata:
dbdata:Three design decisions worth remembering:
- Only
apphasports:. The database needs no open host port – the app reaches it internally via the hostnamedb(theDATABASE_URLpoints exactly there). A port that isn’t published is a port nobody from the internet can attack. - Two separate named volumes. App data and database files live cleanly separated – that makes later backups and restores traceable.
- Password only as
${DB_PASSWORD}. The actual value is in the.env, not in this file. The samecompose.yamlcan thus be shared safely.
This very skeleton – app facing outward, database internal only, data in named
volumes, secrets in the .env – repeats in Nextcloud, Vaultwarden, Paperless and
most other recipes.
When things go wrong
yaml: line 7: did not find expected key (or similar YAML errors). YAML is
indentation-sensitive – spaces only, never tabs, and consistent per level
(common: 2 spaces). Check the file without starting it: docker compose config
resolves everything and complains about exactly the wrong line.
Error ... address already in use on up. The host port (left in 8080:80) is
already taken. Find the occupant with sudo ss -tlnp | grep 8080 or choose a
different host port. Two containers cannot share the same host port.
An app can’t find its database (could not translate host name). The hostname
must be the service name (e.g. db), not localhost. Inside a container,
localhost is the container itself, not the neighboring service. And: both services
must be in the same Compose project (the same file).
After docker compose down all data is gone. Either the volume wasn’t declared
as a named volume under volumes: (then it was only the ephemeral container
storage), or down -v was used. Always run persistent services with a declared named
volume.
docker-compose: command not found. That’s the old Compose v1 (with a hyphen).
The current one is docker compose (with a space, plugin). If it’s missing: sudo apt install docker-compose-plugin (see Docker
tutorial).
Maintenance & backups
- Maintain image tags: Fixed tags (
nginx:1.31) mean you apply updates deliberately by bumping the tag. That’s intended – this way you decide when an update comes, instead of being surprised. Expect a monthly look at your services’ release notes. - What belongs in the backup? Not the containers – they can be rebuilt from the
compose.yamlat any time. What you must back up are the named volumes (or bind-mount folders), thecompose.yamland the.env. We build a well-thought-out off-site backup of this data with encrypted Restic backups. - Cleanup:
docker compose downwhen tearing down a stack; you remove unused images withdocker image prune. Named volumes are never deleted automatically – that’s by design.
Last updated: Jul 19, 2026
Send feedback: feedback@serverkueche.de
What's next?

Monitoring with Grafana & Prometheus: your server in live dashboards
A complete monitoring stack of Prometheus, node-exporter, cAdvisor and Grafana behind Traefik – with live metrics for …

Setting up Traefik: reverse proxy with automatic HTTPS
Traefik as a reverse proxy in front of your containers, with automatic Let's Encrypt certificates: every app gets a …
You might also like

CrowdSec: modern, collaborative intrusion prevention
Set up CrowdSec with the Traefik bouncer: detect attacks from the access logs, block attackers with a 403 and benefit …

Self-hosting Jellyfin: your own media server behind Traefik
Set up Jellyfin with Docker behind Traefik: stream your movies, series and music – with HTTPS and the library on …

Self-hosting Immich: your private photo backup behind Traefik
Set up Immich with Docker behind Traefik: the self-hosted alternative to Google Photos – with automatic phone backup, …