Skip to content

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

services:
  sonarr:
    image: lscr.io/linuxserver/sonarr:4.0.20.3014-ls329    # not :latest

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.x minor bump is treated as a major, since that's where they break things.
  • LinuxServer images: the -lsNNN suffix is their rebuild counter, so a versioning rule makes an -ls bump 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
.env.example
PUID=<PUID>
PGID=<PGID>
TZ=<TZ>
APPDATA_PATH=/mnt/appdata
MEDIA_DATA=/path/to/media
  • CMD runs the binary directly. Use CMD-SHELL when you need a pipe or ||: ["CMD-SHELL", "wget -qO- http://localhost:8080 >/dev/null 2>&1 || exit 1"].
  • start_period is 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=autoheal means "only containers labelled autoheal=true". all would 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