Blog / / 4 min read
One Compose project per directory
How I lay out the Docker side of my homelab so every stack can be deployed, validated and understood on its own.
My first homelab repo had a folder called docker-compose/ with a bunch of files like streaming.yml and gitea.yml sitting next to each other, plus a shared .env at the top. To deploy something I’d scp the file to the host as compose.yml and run it there.
It worked. It also meant that the thing running on the server and the thing in my repo slowly stopped matching, and I never really knew which one was right.
So when I started homelab-docker, I gave myself one rule: every top level folder is one project, and it has everything it needs.
The layout
<project>/
compose.yml # always named compose.yml
.env.example # every key documented, fake values
.env # real values, never committed
README.md # short, points at the wiki page
Dockerfile # only if it builds an image
data/ # tracked config that the stack reads
That’s it. To deploy anything it’s the same three steps every time:
cd <project>
cp .env.example .env && $EDITOR .env
docker compose up -d
Right now that’s about a dozen projects: my Gitea server (local only, reachable from the homelab network or VPN) and its runners, nightly backups, Nginx Proxy Manager with Authelia in front of it for SSO, LibreChat, a Lancache for game downloads, the media stack, Kavita, a Fabric Minecraft server, node exporter and cAdvisor for monitoring, an AirPrint bridge for my printer, LocalSend, and a WireGuard setup with keepalived for failover.
The rules that made it work
Secrets never go in the repo. Every .env is gitignored. Every project ships a .env.example that lists every key with a comment and a fake value. The example file is the documentation for what the stack needs.
Runtime state is gitignored too. Database files, caches, Minecraft world saves, runner state. Only configuration gets tracked. If I can’t rebuild it from the repo plus a backup, something is wrong.
Shared stuff lives in one place. A few stacks need the same config, like the Authelia and nginx snippets. Those live in a root data/ folder instead of being copied around.
Not everything has to be a container. LocalSend doesn’t ship an image, so that folder installs a pinned binary and a systemd user unit instead. It still lives in the same repo with the same layout, just without a compose.yml. I’d rather bend the rule a little than run some sketchy unofficial image.
One shared network. Most stacks join an external Docker network called linares-net, so the reverse proxy can reach them by container name. A few use host networking where they have to.
CI catches the dumb mistakes
The repo has a validate workflow that runs on every push, on my own Gitea runners. It does three things:
- Runs
docker compose config -qagainst every*/compose.yml, so a typo or a bad indent fails the push instead of failing on the server. - Lints all the YAML with yamllint.
- Runs shellcheck on the backup scripts, since those get deployed by CI and stop containers as root. That’s the one place I really don’t want a quoting bug.
The compose check is a tiny loop:
for f in */compose.yml; do
if docker compose -f "$f" config -q; then
echo "OK $f"
else
echo "FAIL $f"; fail=1
fi
done
It’s not fancy, but it means a bad indent fails in CI instead of on the server.
Renovate also runs against the repo and opens PRs when images update, which beats me remembering to check release pages.
Docs live in the wiki
Every project’s README is basically a stub that says “see the wiki.” The real operational docs, like how to restore a backup, why a container stops itself, or what port something needs, live on my private Gitea wiki for the repo. I tried keeping long READMEs in each folder and they always went stale. A wiki is easier to edit when I’m in the middle of fixing something, which is exactly when I learn the stuff worth writing down.
What I’d tell past me
Pick a layout and be boring about it. The best part of this setup isn’t any one stack, it’s that every stack looks the same. When something breaks at night I don’t have to remember how I set that one up, because I set them all up the same way.