For a long time my homelab was a museum of how I'd felt about containers on any given weekend. Some services were docker run lines buried in shell history. A couple were lovingly hand-built with Portainer stacks I'd long since forgotten the shape of. One particularly cursed thing ran from a screen session that I was frankly afraid to detach. It all worked, in the sense that the lights were green, but I couldn't have told you how to rebuild any of it.
The fix wasn't clever. It was just Compose, applied with discipline. One directory per service, each with its own compose.yaml, all of it in a git repo I can clone onto a fresh box and bring up with one command.
one file per service, one source of truth
The rule I settled on: if it isn't in the repo, it doesn't exist. No more secrets living only in a container's environment, no more "oh that volume, I think it's somewhere under /opt". Each service gets a folder, a Compose file, and a .env that's gitignored but documented by a checked-in .env.example.
services:
jellyfin:
image: jellyfin/jellyfin:10.10.3
container_name: jellyfin
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
volumes:
- ./config:/config
- /tank/media:/media:ro
ports:
- "8096:8096"
The thing I'd been resisting for years, and shouldn't have, is pinning image tags. latest is how you discover at 23:00 on a Sunday that a database migrated its schema while you weren't looking. Pinning means upgrades are a deliberate act: bump the tag, read the changelog, docker compose up -d, watch the logs. Boring. Boring is the goal.
the bits that make it bearable
A shared external network so containers can talk to the reverse proxy without me threading ports everywhere:
docker network create proxy
Then each service joins proxy as an external network and Caddy does the TLS and routing. The upshot is that adding a new service is genuinely a five-minute job: drop a folder in, add a label or two, up -d, done.
I also wrote a daft little wrapper that walks the tree and runs docker compose pull && up -d per directory, so a full update is one command without bundling everything into a single monstrous file. I tried the single-file approach early on and it became unmanageable around the tenth service. Separate files, shared network, shared conventions: that's the sweet spot.
It is not a fashionable setup. There's no Kubernetes, no GitOps operator reconciling state every thirty seconds. But I can read the whole thing in an afternoon, and when something breaks I know exactly which file to open. For a house, that's the right amount of infrastructure.