Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Containers Difficulty: Intermediate

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.

· 11 min read ·Duration: approx. 40 minutes
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

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:

Terminal
mkdir -p ~/compose-demo/site && cd ~/compose-demo

Create a small HTML page that we’ll serve in a moment:

Terminal
echo '<h1>Hallo aus der Serverküche</h1>' > site/index.html

And now the central file compose.yaml:

YAML
services:
  web:
    image: nginx:1.31
    ports:
      - "8080:80"
    volumes:
      - ./site:/usr/share/nginx/html:ro
    restart: unless-stopped

Line 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 use latest: latest changes under you and makes errors unreproducible.
  • ports: - "8080:80" – format HOST:CONTAINER. Port 80 in the container is mapped to 8080 on the server. The server is always on the left.
  • volumes: - ./site:...:ro – the local site folder 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) and on-failure (only after a crash with an error code). For the vast majority of services, unless-stopped is exactly right.

Start the stack:

Terminal
docker compose up -d

-d means detached (in the background). The first time, Docker downloads the image; after that you see at the end:

Ausgabe
 Network compose-demo_default  Created
 Container compose-demo-web-1  Started

Check the status:

Terminal
docker compose ps
Ausgabe
NAME                 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/tcp

The container is called compose-demo-web-1project folder + service + number. Test:

Terminal
curl localhost:8080
Ausgabe
<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:

YAML
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:

Terminal
docker volume ls
Ausgabe
DRIVER    VOLUME NAME
local     compose-demo_webdata

Now comes the crucial point:

Terminal
docker compose down
docker volume ls
Ausgabe
 Container compose-demo-web-1  Removed
 Network compose-demo_default  Removed
DRIVER    VOLUME NAME
local     compose-demo_webdata

docker 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:

YAML
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 starts web before ping. (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. ping calls http://webweb is the service name from the same file.

Run only the ping service once:

Terminal
docker compose run --rm ping
Ausgabe
<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:

Terminal
docker network create proxy

In the compose.yaml you then don’t create it again, but reference the already existing network with external: true:

YAML
services:
  web:
    image: nginx:1.31
    networks:
      - proxy

networks:
  proxy:
    external: true

external: 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:

YAML
services:
  app:
    image: beispiel/app:1.0
    environment:
      - TZ=Europe/Berlin
      - APP_PORT=3000

Secrets (passwords, tokens) do not belong in the compose.yaml, but in a .env file in the same folder. Compose reads it automatically:

Terminal
echo "DB_PASSWORD=EIN_LANGES_ZUFALLSPASSWORT" > .env
YAML
services:
  db:
    image: postgres:18
    environment:
      - POSTGRES_PASSWORD=${DB_PASSWORD}

Never push `.env` to a backup repo

The .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:

Terminal
docker compose config

config resolves all variables and prints the finished, normalized configuration:

YAML
name: compose-demo
services:
  db:
    environment:
      POSTGRES_PASSWORD: EIN_LANGES_ZUFALLSPASSWORT
    image: postgres:18
    networks:
      default: null

If 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:

YAML
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_healthy

With 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:

Terminal
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.yamldocker compose pulldocker 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:

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:

  1. Only app has ports:. The database needs no open host port – the app reaches it internally via the hostname db (the DATABASE_URL points exactly there). A port that isn’t published is a port nobody from the internet can attack.
  2. Two separate named volumes. App data and database files live cleanly separated – that makes later backups and restores traceable.
  3. Password only as ${DB_PASSWORD}. The actual value is in the .env, not in this file. The same compose.yaml can 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.yaml at any time. What you must back up are the named volumes (or bind-mount folders), the compose.yaml and the .env. We build a well-thought-out off-site backup of this data with encrypted Restic backups.
  • Cleanup: docker compose down when tearing down a stack; you remove unused images with docker image prune. Named volumes are never deleted automatically – that’s by design.

Last updated: Jul 19, 2026

What's next?

You might also like