Docker Compose Basics: What's Actually Happening Before You Copy Anyone's compose.yaml
Every guide on this site — the media stack, Cloudflare Tunnel, Tailscale, the recipe managers — starts with a block that looks like this and just expects you to know what it means:
services:
app:
image: someproject/app:latest
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
volumes:
- /path/to/appdata/app:/config
ports:
- "8080:8080"
If that's already obvious, skip this one. If it isn't, this is the primer — what each line actually does, and why this site's guides all repeat the same handful of patterns.
Summary
- A container is a running process; an image is the read-only template it starts from. Compose's job is describing a group of containers as one file.
- Volumes are what survive a container being deleted and recreated — put anything you'd be upset to lose in one.
restart: unless-stoppedmeans "come back after a reboot," not "restart forever." That distinction matters.PUID/PGIDaren't Docker features — they're an convention certain images (LinuxServer.io's, mainly) read to avoid running as root.
Image vs. container, in one sentence
An image is a frozen filesystem plus some metadata — think of it as a program you haven't run yet. A container is that image actually running: its own process, its own writable layer on top of the image, isolated from the host and from other containers unless you explicitly connect them.
This is why docker compose up -d and docker compose restart behave differently.
up -d can recreate a container from the image (picking up a new image version, a
changed environment variable); restart just stops and starts the existing
container's process, changing nothing about its configuration. If you edit a compose
file and only run restart, your edits won't apply — this is the single most common
"I changed it but nothing happened" moment in Docker.
The compose file is just: which images, connected how
A compose.yaml describes one or more services — each service becomes one
container (usually named after it) — and how they relate:
services:
app:
image: someproject/app:latest # which image
container_name: app # the name docker ps shows, and how other
# containers on the same network can reach it
restart: unless-stopped # what to do if it crashes or the host reboots
environment: # env vars passed into the container
- PUID=1000
- PGID=1000
volumes: # host path : container path
- /path/to/appdata/app:/config
ports: # host port : container port
- "8080:8080"
Every guide on this site follows this exact shape. Once you can read this block, you can read all of them.
Volumes: the only thing that survives
Delete a container and its writable layer goes with it — anything the app wrote
inside the container's own filesystem is gone. A volume (here, a bind mount —
a host folder mapped in) is how you keep what matters: point /config at a real
folder on your host, and recreating the container changes nothing about the app's
data, because the data was never inside the container to begin with.
volumes:
- /path/to/appdata/app:/config
Read this as host path : container path. The app writes to /config from its own
point of view; you see those files at /path/to/appdata/app on the host. This is why
every guide on this site puts appdata in a predictable place — the environment table
in the media-stack series is exactly this convention
applied consistently.
The rule that matters most: if losing a folder would mean losing something real (a database, uploaded photos, a config you tuned by hand), it needs a volume. If it's genuinely disposable (cache, temp files), it doesn't.
Networks: containers finding each other by name
Containers in the same compose file share a network by default, and can reach each
other by service name — not localhost, not an IP:
services:
app:
depends_on: [db]
environment:
- DB_HOST=db # not "localhost", not an IP — the other service's name
db:
image: postgres:16-alpine
This is why every guide on this site says things like "host sonarr, port 8989" —
sonarr isn't a hostname you configure anywhere; it's just the other service's name
in the same compose file, and Docker's internal DNS resolves it automatically. It only
works between containers started from the same compose file (or explicitly joined
to the same external network) — a container in a different stack can't reach sonarr
by name unless you wire that up deliberately.
restart: unless-stopped — the one policy you actually want
Four options exist, and one of them is right almost all the time:
| Policy | Behaviour |
|---|---|
no (default) | Never restarts automatically, not even after a reboot |
on-failure | Restarts only if the process exits with an error |
unless-stopped | Restarts on crash and on host reboot — unless you stopped it manually |
always | Same as unless-stopped, but also restarts a container you deliberately stopped, the next time the Docker daemon starts |
unless-stopped is what you want for anything you expect to run continuously: it
survives a host reboot, but if you docker stop app to work on something, it stays
stopped instead of fighting you.
PUID / PGID — not Docker, a convention
Docker itself has no PUID/PGID concept. These are environment variables that
certain images (most notably LinuxServer.io's)
read at startup and use to switch the process to run as that user/group instead of
root, before touching your files.
environment:
- PUID=1000
- PGID=1000
Find your own values with id <your username> on the host — 1000 is a common
default for the first non-root user on Linux, not a value to copy blindly. Set the
same values in every container that shares a volume, or you'll spend an afternoon
debugging "permission denied" errors that are really just two containers disagreeing
about who owns a file. This exact trap — and the fix — comes up constantly across
this site's guides.
Healthchecks: telling Docker what "working" means
By default, Docker only knows if a container's process is running, not whether the app inside it actually works. A healthcheck closes that gap:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
docker ps then shows (healthy) or (unhealthy) instead of just Up, and other
tools (Dockhand, depends_on: condition: service_healthy) can act on it — a database
container that's merely "running" isn't necessarily ready to accept connections yet;
service_healthy is what actually waits for it.
Checkpoint
You now have the vocabulary every other guide on this site assumes:
- Image vs. container, and why
restartalone won't pick up compose file changes - Volumes are what survives; put real data behind one
- Services reach each other by name, only within the same compose file
restart: unless-stoppedfor anything meant to run continuouslyPUID/PGID— an image convention, not a Docker feature, and it must match across containers sharing a volume- Healthchecks tell Docker (and everything watching it) what "actually working" means
From here, the media stack series is the deepest worked example on this site — seven real compose files building on each other. The Cloudflare Tunnel and Tailscale guides assume everything above and won't re-explain it.
If this saved you some time, a coffee keeps the lights on and the posts coming.
☕ Buy me a coffee