For an embarrassingly long time my homelab was run by docker run commands I'd typed once and forgotten. Each service was a paragraph of flags I'd cobbled together at some point, lost to shell history, kept alive only because the container hadn't crashed. The day a host needed reinstalling I realised I had no idea how to bring any of it back. That was the prompt to put the whole house into Docker Compose.
The point of Compose here isn't the YAML, it's that the state of my infrastructure becomes a file I can read, diff, and commit. If a machine dies, recovery is git clone and docker compose up -d, not an afternoon of archaeology.
I keep one repo with a directory per service, each with its own compose.yaml, rather than one enormous file. A single file would technically work, but a typo in one service shouldn't block the lot, and per-service files mean I can restart Pi-hole without thinking about Jellyfin. Shared things, the network and common settings, live in a small base file that each service extends.
A representative service looks like this:
services:
jellyfin:
image: jellyfin/jellyfin:10.10.0
container_name: jellyfin
restart: unless-stopped
networks: [edge]
volumes:
- ./config:/config
- /srv/media:/media:ro
environment:
- TZ=Europe/London
labels:
- "traefik.enable=true"
networks:
edge:
external: true
The decisions in there that earned their place: pinned image tags, never latest, so an unattended pull can't silently change a working service under me. restart: unless-stopped so a reboot brings everything back without my involvement. Volumes for config kept relative to the compose file so the whole service is self-contained in its directory. And an external shared network so Traefik out front can route to anything without me wiring ports by hand.
Secrets stay out of the repo, of course. They live in .env files that are gitignored, with a committed .env.example documenting what each service expects. It's not vault-grade, but it's honest about what's sensitive and stops me pasting a token into a commit at half ten on a Tuesday.
The one piece I resisted at first was a reverse proxy, on the grounds that it was more moving parts. I was wrong. Putting Traefik in front meant I stopped publishing ports by hand and stopped remembering which service lived on which arbitrary high port. Now a service declares a label, Traefik notices it, and it's reachable by name with a certificate already sorted. The compose files got simpler, not more complex, because the per-service port-mapping bookkeeping just evaporated. That's the recurring theme of this whole exercise: every time I made the setup more declarative, the amount I had to keep in my head went down.
The real win showed up when I genuinely did rebuild a host last week. Fresh OS, install Docker, clone the repo, docker compose up -d in each directory, point the volumes at the restored data, done. What used to be a day of remembering became twenty minutes of waiting for pulls. The lab finally describes itself, and I can read what I'm running instead of trusting that past me knew what he was doing.