Compose Conventions¶
Every service I run is a Docker Compose stack, and every stack follows the same few rules. They exist because each one fixed a real problem: a surprise major-version upgrade, a container that was "Up" but dead for days, a deploy that failed halfway because of a typo.
| Rule | Why |
|---|---|
One stack per directory: compose.yaml + .env |
docker compose reads .env next to the file; one place per service |
State in bind mounts under /mnt/appdata/<service> |
One directory tree to back up and restore |
| Every image pinned to a version tag | Updates happen when I merge them, not whenever a container restarts |
| Renovate opens a PR for each new version | Release notes in front of me; rollback is a git revert |
| A healthcheck on anything that serves something | Up only means the process exists; healthy means it answers |
depends_on: condition: service_healthy for hard dependencies |
The app doesn't start before its database or VPN is ready |
| autoheal restarts containers that go unhealthy | Docker itself never restarts an unhealthy container |
docker compose config -q before every deploy |
A YAML or variable error fails in a second, before anything is stopped |
Secrets only in .env, never in git |
The compose files are in a repo; .env is written by the deploy tool |
Directory layout¶
/opt/docker/<stack>/ # hand-managed stacks (or the deploy agent's checkout of the git repo)
├── compose.yaml
└── .env # mode 600, not in git
/mnt/appdata/<service>/ # the service's state: config, databases
Most of my stacks aren't on disk by hand at all: the compose files live in a git repo and the Komodo periphery agent
checks them out and deploys them. See GitOps. Only the deploy tool itself, which
can't deploy its own control plane, lives in /opt/docker/.
Pin every image¶
With :latest, the version you run depends on when the container last happened to be pulled. A reboot or a
compose pull for an unrelated reason can jump you a major version with a config migration you didn't read about,
and there's nothing to roll back to. With a pinned tag:
- the compose file says exactly what's running,
- an update is a one-line diff you can review and revert,
- a deploy that only changes config never needs to contact a registry.
Renovate watches the repo and opens a pull request for each new tag, with the release notes. My policy:
- Patch and minor updates merge themselves, but only during the day, so a bad one lands while I'm around to see the alert.
- Wait for review: every major version; databases (Postgres, Redis/Valkey, MongoDB); the password manager; apps whose parts must move together (Immich server + machine learning, grouped into one PR); and the remote-access path (tunnel + reverse proxy), because a bad release there locks me out while I'm away.
- Pre-1.0 software (Frigate, Beszel, …): a
0.xminor bump is treated as a major, since that's where they break things. - LinuxServer images: the
-lsNNNsuffix is their rebuild counter, so a versioning rule makes an-lsbump a patch. - An image with no version tags at all gets pinned by digest (
:latest@sha256:...) and those updates are manual.
How the PR turns into a deploy: GitOps.
Healthchecks¶
A healthcheck is a command Docker runs inside the container on a schedule. Use whatever HTTP tool the image already
has (wget or curl), and an endpoint that proves the app is answering. From my media stack:
services:
sonarr:
image: lscr.io/linuxserver/sonarr:4.0.20.3014-ls329
container_name: sonarr
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ${APPDATA_PATH}/sonarr/config:/config
- ${MEDIA_DATA}:/data
ports:
- 8989:8989
restart: always
# Servarr apps expose /ping specifically for this
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8989/ping"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
CMDruns the binary directly. UseCMD-SHELLwhen you need a pipe or||:["CMD-SHELL", "wget -qO- http://localhost:8080 >/dev/null 2>&1 || exit 1"].start_periodis a grace period: failures during it don't count, so slow starters (Plex, databases) aren't marked unhealthy while booting.- Check from the host with
docker inspect --format '{{json .State.Health}}' <container>.
Start order: depends_on with service_healthy¶
Plain depends_on only waits for the other container to start. With a condition it waits until it's healthy.
My torrent client must never start before the VPN tunnel is up:
services:
qbittorrent:
# ...
network_mode: "service:gluetun"
depends_on:
gluetun:
condition: service_healthy
The same pattern goes on every app that needs its database: condition: service_healthy on the Postgres/Valkey
service, which has its own pg_isready / valkey-cli ping healthcheck.
autoheal: restart what goes unhealthy¶
restart: always / unless-stopped only reacts when the process exits. A container that hangs but keeps running
stays Up (unhealthy) forever. autoheal watches health status and
restarts containers that turn unhealthy. It's opt-in per container by label:
services:
autoheal:
image: willfarrell/autoheal:latest@sha256:5faf6731257cd57316c2863e40b56bbd148760e867ac689311c08924a266312c
container_name: autoheal
restart: always
environment:
- AUTOHEAL_CONTAINER_LABEL=autoheal
- AUTOHEAL_INTERVAL=30
- AUTOHEAL_START_PERIOD=60
volumes:
- /var/run/docker.sock:/var/run/docker.sock
qbittorrent:
# ...
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080 >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
labels:
- autoheal=true
AUTOHEAL_CONTAINER_LABEL=autohealmeans "only containers labelledautoheal=true".allwould watch everything, which I don't want for databases: restarting Postgres in a loop hides the real problem.- A container without a healthcheck is never restarted by autoheal; it has no health status to act on.
- autoheal needs the Docker socket, which is root on the host. Pin the image and keep it on the short list of things that get the socket.
Validate before you deploy¶
docker compose config -q # exit code only: 0 = valid, otherwise prints the error
docker compose config # print the fully resolved file (with .env values substituted!)
config -q catches YAML indentation errors, unknown keys and missing ${VARIABLES} without touching running
containers. Every stack I apply by hand goes through it first, chained so a failure stops before pull and up -d. Careful with the
non-quiet form: it prints secrets from .env in clear text, so don't paste its output anywhere.
Day to day, by hand:
cd /opt/docker/<stack>
docker compose config -q && docker compose pull && docker compose up -d
docker compose ps # everything Up, and (healthy) where there's a healthcheck
Restart policies¶
| Policy | Restarts after a crash | After reboot / daemon restart | After docker stop |
|---|---|---|---|
no (default) |
No | No | No |
on-failure[:N] |
Only on non-zero exit (up to N times) | Not guaranteed | No |
unless-stopped |
Yes | Yes, unless you stopped it | Stays stopped |
always |
Yes | Yes | Starts again on daemon restart |
unless-stopped is my default: I can stop something for maintenance and it stays stopped across a reboot.
on-failure:2 suits one-shot or scheduled helpers that shouldn't loop forever.
Sources & further reading¶
- Compose file reference: services (
healthcheck,depends_on,restart) - docker compose config
- Dockerfile HEALTHCHECK
- Renovate: Docker Compose manager · automerge
- willfarrell/docker-autoheal
- Lessons learned: Docker & systemd