mabel.ink

My home lab is many compose files, not one

July 22, 2026

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:

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.