My home services had grown the way these things do, by accretion. A Pi running one thing, an old laptop running another, a service I'd installed directly onto the NAS and then forgotten how. None of it was documented, all of it was load-bearing in some small domestic way, and when the laptop's disk finally started clicking I had no clean way to move what it ran. That was the push. I spent a weekend folding the lot into a single docker-compose.yml on one reasonably specced box, and six months later it's the homelab decision I'd most readily repeat.
The pitch for Compose at home is simple. The entire state of "what runs on this machine" lives in one file I can read, version, and back up. When I want to know what's running I read the file. When I want to move it all to new hardware I copy the file and the volumes. There's no longer a service that exists only as a thing I did once at a shell prompt and can't reconstruct.
one file, sensible sections
The file itself is unremarkable, which is the point. Each service is a dozen lines: image, restart policy, the ports it needs, the volumes it owns, and a couple of environment variables. A trimmed example:
version: "3.7"
services:
reverse-proxy:
image: traefik:1.6
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik:/etc/traefik
dns:
image: pihole/pihole:latest
restart: unless-stopped
ports:
- "53:53/tcp"
- "53:53/udp"
volumes:
- ./pihole/etc:/etc/pihole
environment:
- TZ=Europe/London
git:
image: gitea/gitea:latest
restart: unless-stopped
volumes:
- ./gitea:/data
restart: unless-stopped on everything means the stack survives a reboot without me, which matters because the box lives in a cupboard and I'd like to think about it as rarely as possible. The volumes are all bind mounts under the project directory rather than named volumes, deliberately, so that "back up the homelab" is just "tar one directory". Named volumes are tidier but they hide where your data lives, and at home I'd rather see it.
the networking was the interesting bit
The first naive version exposed a port for every service and I navigated to things by remembering port numbers, which is exactly as pleasant as it sounds. The fix was a reverse proxy out front, Traefik in this case, mostly because it can read labels off the running containers and configure itself. I add a few labels to a service and it gets a hostname, no separate config file to keep in sync with reality:
git:
image: gitea/gitea:latest
restart: unless-stopped
labels:
- "traefik.frontend.rule=Host:git.home.lan"
volumes:
- ./gitea:/data
Now everything answers on a friendly name on my LAN. Pi-hole resolves *.home.lan to the box, Traefik routes by hostname, and I never type a port number again. That combination, local DNS plus a labelling reverse proxy, is what took the setup from "a pile of containers" to something that feels like a small private platform.
The one genuine wrinkle was DNS and the proxy depending on each other in a cycle: Pi-hole resolves the names, but it's also a service behind the same box. I keep Pi-hole on a fixed host port and point the router's DNS straight at the box's IP rather than a hostname, so resolution never depends on resolution. It's the sort of bootstrapping problem you only notice the first time the whole thing comes up cold after a power cut.
where it stops being enough
I'll be honest about the limits, because the "everything in one Compose file" posts rarely are. This is one machine. If it dies, the house goes quiet until I fix it, and a single docker-compose.yml is a single point of failure dressed up as tidiness. For a home that's an acceptable trade: the blast radius is me being mildly inconvenienced, not a business. But I wouldn't pretend it's high availability, and I don't run anything here that anyone but my household depends on.
Compose also has nothing to say about updates beyond pull and up -d, which has bitten me when a :latest tag moved under me and a service came back subtly broken. I've started pinning the images that matter to real version tags and only floating :latest on the ones I don't mind babysitting. That's a small discipline that turns "why did DNS stop working overnight" into a non-event.
The headline result is the one I wanted: the whole house now runs from a file I understand, on hardware I can replace in an afternoon. When I inevitably outgrow one box, the file is also the migration plan. That's worth a weekend of folding things together, and it's worth the small ongoing discipline of keeping it honest.