My home lab is many compose files, not one
When I started containerising the services on my home server, I pictured one docker-compose.yml
with everything in it. Media server, database, DNS, Git, the lot. One file, one command, whole
lab up.
What I ended up with is a directory per service, each with its own compose file. For a while I assumed that had happened by accident. It hadn't, and the reason is worth being explicit about.
What one big file actually costs
A compose file defines a project, and the lifecycle commands operate on the project. That is the whole issue.
With everything in one file:
docker compose downstops every service. Restarting a static web server takes DNS and the media server down with it, and everyone in the house notices.docker compose up -dafter editing one service re-evaluates all of them, and can recreate containers you had no intention of touching.- One YAML mistake breaks your ability to manage anything, not just the service you were editing.
- Backup and restore become all or nothing.
That is a blast radius problem. The same reason you would not put every scheduled task into one script that runs them in sequence. Independent things should be able to fail independently, and more importantly, should be able to be worked on independently.
Per-service directories give each stack its own lifecycle. I can rebuild the web server at midday without thinking about whether anyone is streaming something.
What the split costs
Exactly one thing, and it is not obvious until it bites.
Compose puts each project on its own bridge network by default. Containers in different projects cannot resolve each other by name. So the moment I wanted a reverse proxy in one stack to forward to a web server in another, name resolution simply did not work.
The fix is a shared network created outside any compose file:
docker network create proxy
Then each stack declares it as external and attaches the containers that need it:
services:
staging:
# ...
networks:
- proxy
networks:
proxy:
external: true
external: true means "this already exists, do not create it and do not delete it on down."
Without that, compose would try to manage a network that other projects depend on.
If you have done any Hyper-V work the mapping is direct. Creating the network is creating a
virtual switch. The top level networks block with external: true is selecting the existing
switch rather than making a new one. The service level networks key is attaching that
machine's adapter to it.
The part that made it click
Once the containers share a network, the proxy reaches its backend by container name and internal port:
reverse_proxy mabel-staging:80
Not the host's IP, and not the published port. Port 80 there is the port inside the container. Docker's embedded DNS resolves the container name on the shared network, and the traffic never touches the host's network stack at all.
Which means the backend does not need a published port. I removed the 8080:80 mapping from
the web server entirely. It is now unreachable except through the proxy.
That is the actual win, and it is a security one rather than an aesthetic one. Three services each exposing a port to the LAN became one ingress point that everything passes through. One place holding certificates, one place with access logs, one thing to audit.
If you want one command back
Compose supports a top level include: key, so a root file can pull in the others and give you
a single entry point when you want one, while each stack stays independently manageable. A small
script that loops the directories does the same job.
I have not bothered. Typing cd ~/containers/caddy first has never once been the slow part.
The takeaway
The split is not a failure to organise. It is paying one small, well understood tax, a shared network you create by hand, in exchange for independent lifecycles and a much smaller blast radius.
If your compose file contains services that have no reason to restart together, that is your signal.