For about two years my homelab was a pile of docker run commands held together by my memory and a text file called notes.txt. Pi-hole here, Jellyfin there, a Gitea I'd forgotten the flags for, an arr stack I was actively afraid of. Every reboot was an adventure. Every "did I expose that port?" was answered by docker ps and a squint. It worked right up until it didn't, which in this case meant a power cut over Christmas that brought everything back in the wrong order and none of it talking to anything else.
So I did the boring thing and moved the lot into Compose. Not Kubernetes, not Nomad, not anything I'd have to explain to myself at 2am. One directory, one stack per concern, everything in git.
The layout
The whole thing lives in ~/stacks, one folder per service group, each with its own compose.yaml and an .env that never gets committed.
stacks/
dns/ # pi-hole + unbound
media/ # jellyfin + the arr stack
git/ # gitea + runner
monitoring/ # prometheus + grafana
.env.example
I deliberately did not put everything in one giant file. The temptation is real: one docker compose up and the house comes alive. But a single 400-line Compose file is a single 400-line thing to break, and I don't want a Grafana restart to risk DNS. Splitting by blast radius means I can bounce the media stack without anyone noticing the lights flicker on the network.
The conventions that actually matter
A few rules, learned by getting them wrong first.
Every service gets restart: unless-stopped, never always. The difference bites you exactly once: always will cheerfully resurrect a container you stopped on purpose after a daemon restart, and you'll spend twenty minutes wondering why the thing you killed is back.
Named volumes, not bind mounts into random home directories. The data I care about lives under a single appdata path that my backup job knows about. If a volume isn't under there, it's disposable by definition, and that clarity is worth more than it sounds.
Pinned image tags. Not latest. I got burned by a Jellyfin point release that changed a database migration, and latest meant I couldn't tell what I'd been running before. Now everything is pinned, and updates are a deliberate git commit that bumps a tag, not a surprise on the next pull.
Healthchecks where the service supports them, so depends_on with condition: service_healthy actually means something. Unbound being "started" is not the same as Unbound answering queries, and Pi-hole pointing at a resolver that isn't ready yet is its own special flavour of broken.
Was it worth it
Yes, plainly. The reboot test now passes: pull the power, plug it back in, walk away, and ten minutes later the house is back without me touching a keyboard. More to the point, when I want to know what's running and how, I read a file instead of interrogating a daemon. The homelab stopped being something I maintained from memory and became something I could hand to a future, more tired version of myself. That's the whole win. Not the containers, the fact that the knowledge lives in git now instead of in my head.