Docker Compose Patterns That Age Well

The compose files you never have to rewrite

Contents

I have compose files in my homelab that I have not touched since 2022. I also have compose files I seem to rewrite every time the moon is full. The difference between the two has almost nothing to do with the application inside them and almost everything to do with a handful of habits I picked up the hard way, usually at some ungodly hour after a docker compose pull quietly detonated a service that had been running fine for eighteen months.

This post is the accumulated scar tissue: the patterns that, in my experience, decide whether a compose file becomes a stable fixture you forget about or a recurring chore that follows you around like an unpaid invoice. None of it is clever. Most of it is boring. Boring is the entire point — a compose file that ages well keeps doing exactly what it did last year, and the way you get there is by removing every reason for it to surprise you.

Why files rot, and why some don’t

Advertisement

Before the how, the why, because the why is what makes the rules stick. A compose file rots when it depends on something outside itself that is free to change. image: postgres:latest is a dependency on “whatever the maintainers pushed most recently”, which is a promise nobody made you. A bare depends_on is a dependency on “the database process boots faster than my app”, which is true right up until the day it isn’t. A bind mount to /home/smarc/data is a dependency on the machine you first ran it on. Each of these is a small bet that the world will hold still, and the world does not hold still.

A file that ages well pins its dependencies down until nothing external can move without you deciding it should. The image tag names a real version. The startup order waits for actual readiness. The data lives somewhere with a lifecycle you control. Everything the file needs to reproduce itself is either inside the file or inside a .env file next to it. That is the whole philosophy, and everything below applies that idea to specific keys.

The first and easiest win is deleting something. If your file still starts with version: "3.8", remove that line. The Compose Specification — what docker compose (the v2 plugin) actually implements — ignores the top-level version: key entirely. It does nothing except mislead the next person, who assumes it selects a schema and burns twenty minutes wondering why changing it has no effect. It has been inert for years. Bin it.

While we are on the daemon: everything here works identically under Podman’s podman compose and its Quadlet-adjacent tooling, so if you came to compose specifically to avoid a root daemon, see Podman for people who distrust the Docker daemon — none of these patterns are Docker-specific.

The one file to steal

Here is a compose file that demonstrates most of what I care about: a web application talking to Postgres. Read it once, then I will justify each decision.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# compose.yaml — note: no top-level `version:` key. It is obsolete.

x-app-defaults: &app-defaults
  restart: unless-stopped
  security_opt:
    - no-new-privileges:true
  cap_drop:
    - ALL
  logging:
    driver: json-file
    options:
      max-size: "10m"
      max-file: "3"
  networks:
    - backend

services:
  app:
    <<: *app-defaults
    image: ghcr.io/example/webapp:1.8.2      # a real version, never `latest`
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD:?set DB_PASSWORD in .env}@db:5432/app
      PUID: ${PUID:-1000}
      PGID: ${PGID:-1000}
      LOG_LEVEL: ${LOG_LEVEL:-info}
      # A literal dollar sign in a value must be doubled:
      RATE_LIMIT_NOTE: "costs $$5 per 1000 requests"
    ports:
      - "${HTTP_PORT:-8080}:8080"
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    deploy:
      resources:
        limits:
          memory: 512M

  db:
    <<: *app-defaults
    image: postgres:16.4                       # pinned minor, predictable upgrades
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
    volumes:
      - pgdata:/var/lib/postgresql/data        # named volume: survives `down`
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

volumes:
  pgdata:

networks:
  backend:

That is roughly the shape of every long-lived service I run. Now the reasoning.

The anchor is the trick that pays off for years. x-app-defaults: &app-defaults defines a block once; <<: *app-defaults merges it into each service. The x- prefix marks it as an extension field Compose won’t try to run. Everything shared — restart policy, the hardening flags, log rotation, the network — lives in one place. When I decide next year that every container should also drop a capability or cap its logs differently, I edit one block and both services inherit it. Repetition across services is where drift is born; kill the repetition and you kill the drift.

Image tags name a real version. postgres:16.4, not postgres:latest. latest is not a version, it is a moving target, and the reason a docker compose pull breaks a service at 2am is almost always that latest silently rolled to a new major with a migration you didn’t ask for. Pinning to 16.4 means pull fetches nothing new until I change that string myself, which turns upgrades into a deliberate act I perform when I am awake and have a backup. For the truly paranoid, pin a digest (postgres@sha256:…) and even the tag can’t be re-pushed underneath you.

Environment interpolation carries the config, with defaults baked in. ${PUID:-1000} means “use PUID from the environment or .env, otherwise fall back to 1000”. ${LOG_LEVEL:-info} does the same for log verbosity. The :? variant — ${DB_PASSWORD:?set DB_PASSWORD in .env} — is stronger: it refuses to start and prints that message if the variable is unset, which is exactly what you want for a secret that has no sane default. A .env file in the same directory is auto-loaded, so DB_PASSWORD, HTTP_PORT and friends live there, uncommitted. If a single service needs its own block of variables, env_file: ./db.env pulls them in per-service. And the one that catches everyone: a literal dollar sign in a value must be written $$, or Compose tries to interpolate it. That costs $$5 renders as costs $5 in the container.

Named volumes for state, bind mounts for config. The database writes to pgdata, a named volume declared at the bottom. Named volumes survive docker compose down and recreation, they are portable, and Docker manages their location so the file isn’t tied to a path on one host. Bind mounts I reserve for configuration I edit by hand — a ./nginx.conf:/etc/nginx/nginx.conf:ro sort of thing — because there I want the file on disk where I can vim it. The lifecycle difference is the whole point: state should outlive the container and travel with the stack, config should sit where I can edit it. Mixing them up is how people end up with a database inside a bind mount to a directory they later reorganise, at which point the data goes on an adventure. If this named-volume-versus-explicit-storage distinction feels familiar from Kubernetes, it is the same argument I made in Persistent volumes in K3s without losing your data; compose named volumes are the small-scale cousin of a PersistentVolumeClaim, and the reasoning transfers cleanly.

Healthchecks plus depends_on: condition: service_healthy is the single most misunderstood pair of keys in Compose. Plain depends_on: [db] waits for the db container to start — for the process to exist — and then releases the app. It does not wait for Postgres to finish its own startup, run recovery, and begin accepting connections. So the app launches, tries to connect to a database that is still warming up, and falls over. People then paper over this with sleep loops and retry wrappers in the app’s entrypoint, which is treating the symptom. The fix is the healthcheck on db (here, pg_isready) combined with condition: service_healthy on the app’s depends_on. Now Compose genuinely blocks the app until the database reports healthy. This one change eliminated an entire genre of flaky-startup problems for me.

restart: unless-stopped is the sane default for anything you want to survive a reboot. no never restarts; always restarts even after you deliberately stop it, which fights you during maintenance; unless-stopped restarts on crash and after a daemon restart but respects a manual stop. It is the policy that does what you mean.

Hardening you bake in at the start costs nothing and saves you later. security_opt: [no-new-privileges:true] stops a process inside the container from gaining privileges via setuid binaries. cap_drop: [ALL] throws away every Linux capability the container doesn’t need — most apps need none, and you can cap_add back the rare one that does. A memory limit under deploy.resources.limits stops one runaway service from eating the host. These live in the anchor, so every service gets them for free. Retrofitting security onto a running stack is miserable; putting it in the template means you never have to.

Notice what is not in that file: secrets in plain sight. DB_PASSWORD comes from .env, and .env is gitignored. For anything beyond a homelab I’d go further and keep the secrets encrypted at rest — that is a whole topic of its own, covered in secrets management with SOPS and age.

The patterns that keep it flexible

Advertisement

A file that ages well also has to bend without breaking, and Compose has two features for that which I reach for constantly.

The override file. docker compose automatically merges compose.override.yaml on top of compose.yaml if it exists. So the base file describes production-shaped truth — pinned images, no debug ports, real hardening — and compose.override.yaml, which I gitignore, carries my local dev tweaks: a bind mount of my source directory, an exposed debugger port, LOG_LEVEL=debug. On the server there is no override file, so the base runs clean. On my laptop the override layers in. Same base file, two behaviours, zero conditional cruft inside it.

Profiles keep optional services out of the way. Assign a service profiles: ["backup"] and it will not start on a plain docker compose up — only when you ask for it with docker compose --profile backup up or by setting COMPOSE_PROFILES. I use this for a nightly pg_dump sidecar, and for the occasional debug container full of network tools I only want when something is on fire. They live in the same file, documented and version-controlled, without cluttering the default up — present but dormant, which is exactly where optional stuff belongs.

Declare your networks explicitly. I give the stack a named backend network rather than leaning on the implicit default Compose creates. It is a small thing, but naming the network means I can reason about which services can reach which, add a second network (a frontend for the reverse proxy, say) later without a rewrite, and avoid surprises when two projects on the same host both rely on “the default”. Explicit topology ages better than implicit topology every single time.

Troubleshooting: the things that will still bite you

Even with all of the above, these are the failures I actually hit, and how I unpick them.

“My app can’t reach the database on startup.” Ninety percent of the time this is depends_on without a health condition — the app raced the database and lost. Add a healthcheck to the dependency and condition: service_healthy to the dependent, as above. Confirm the healthcheck itself is sane with docker inspect --format '{{json .State.Health}}' <container>; a healthcheck that never goes healthy (wrong command, missing curl in the image) will hang your up forever, which looks like a deadlock but is really a typo.

Volume permission and ownership mismatches. You mount a named volume, the container writes to it as root, and later a process running as UID 1000 can’t read its own data — or the reverse. This is the PUID/PGID dance. Many images (the LinuxServer.io family especially) read PUID/PGID and drop to that user; set them to match the owner you want on the host side. If an image insists on writing as root and you need otherwise, user: "1000:1000" on the service forces it, but check the image can cope — some need to start as root to chown their data directory first. When a volume’s ownership is already wrong, the fastest fix is usually a throwaway container: docker run --rm -v pgdata:/data alpine chown -R 1000:1000 /data.

Interpolation surprises. Two classic ones. First, an unescaped $ in a password or config value — Compose eats it as the start of a variable reference and you get an empty string or a warning. Double it to $$. Second, an empty variable silently wiping a value: ${TAG} with TAG= set to nothing in .env interpolates to an empty string, so image: app:${TAG} becomes image: app: and fails cryptically. Use ${TAG:-latest} to supply a default, or ${TAG:?tag required} to fail loudly instead of mysteriously. Loud failure beats silent wrongness.

Orphan containers after a rename. Rename a service from web to app and the next docker compose up leaves the old web container running, orphaned, possibly still holding the port. Compose warns about this; the fix is docker compose up -d --remove-orphans, which sweeps containers that belong to the project but no longer appear in the file. I add --remove-orphans reflexively after any structural edit.

Anchors that don’t override the way you expected. The YAML merge key <<: is a shallow merge. If your anchor sets environment: with three variables and a service also sets environment: with one, you do not get four merged variables — depending on how you wrote it, the service’s block can replace the anchor’s block wholesale for that key. Anchors are brilliant for whole self-contained blocks (a logging config, a security_opt list) and fiddly for things you expect to partially override. When in doubt, keep the anchor to the settings that are genuinely identical everywhere and set the per-service differences explicitly.

latest drift, revisited. If a service that “was fine yesterday” breaks after a pull, check whether an image is on a floating tag. docker compose images shows the resolved image IDs; if the digest moved and the tag didn’t, that is your culprit. Pin it to the version that currently works and upgrade on purpose.

When in doubt, render the truth. docker compose config prints the fully merged, fully interpolated file — base plus override, every ${VAR} resolved, every anchor expanded. This is what Compose will actually deploy, as opposed to what you think you wrote. Before any deploy I don’t trust, I run it and read it. It has caught empty variables, a stray override I forgot was active, and an anchor merging in something I didn’t intend. If a stack misbehaves in a way that makes no sense, this command is the first thing to run — it collapses the gap between the file you wrote and the config the daemon sees.

Is it worth it, and for whom

Honestly? For a throwaway container you’ll docker rm by Friday, no. Skip all of it, run docker run, move on. The patterns here are overhead, and overhead only pays back over time.

But for anything you intend to keep — a service your household relies on, a stack you’d be annoyed to rebuild from memory, anything holding data you’d mourn — this is the cheapest insurance I know. It is maybe twenty extra minutes when you first write the file, and it buys you years of not being woken up by an upgrade you didn’t authorise or a startup race you thought you’d fixed. The named volumes mean your data outlives your tinkering, the pinned tags mean upgrades happen on your schedule, the healthchecks mean the stack comes up correctly after a power cut whether or not you’re watching, and the anchors mean the file stays honest as it grows.

I write compose files this way now without thinking about it. It is a small tax that quietly refunds itself the first time a machine reboots at 4am and everything simply comes back up, healthy and in order, while I stay asleep. That, more than any feature, is what “ages well” means: the file you wrote once and never had to think about again.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.