How I lay out Docker Compose stacks on one server
Every service on my main server is a Docker Compose project in its own folder under /opt. Mail, chat, file sync, photos, a time tracker, a few apps I wrote. There’s no orchestrator. When I want to know how something runs, I open one folder and it’s all there: the compose file, its environment, and its data.
One folder, one stack
/opt/
traefik/ docker-compose.yml traefik.toml dynamic/
postgres/ docker-compose.yml PGDATA/
redis/ docker-compose.yml redis_data/
kimai/ docker-compose.yml data/ plugins/
n8n/ docker-compose.yml n8n.env data/
Data lives next to the compose file as a bind mount (./data:/...), not in a named volume buried under /var/lib/docker. That makes backups dull, which is what you want: copy the folder. It also means moving a stack to another host is an rsync and a docker compose up -d. I keep a small script that syncs /opt to a second machine for exactly that reason.
Shared networks, created once
Stacks reach each other through a few external networks that I create by hand. Their subnets live in a plain file next to the stacks, so I never have to remember which range I used:
# docker network create proxy --subnet=... --ipv6
proxy: 172.23.1.0/24
database: 172.23.2.0/24
proxy is the network Traefik watches. database is where Postgres and Redis sit. An app that needs both joins both:
services:
app:
image: example/app
networks: [proxy, database]
environment:
- DATABASE_URL
- MAILER_URL
restart: unless-stopped
networks:
proxy:
external: true
database:
external: true
The database containers join only database. They publish no ports, and Traefik can’t route to them because they don’t share a network. That separation does more for me than any firewall rule I could write inside the containers.
Environment by name
Look at - DATABASE_URL above, with no value. Compose fills it in from the shell or from the .env file beside the compose file. The compose file itself can be read, shared, or pasted into an issue without leaking anything. Stacks with a lot of settings use env_file: instead, with the file kept out of git.
Names that tell you where you are
Shared infrastructure containers get the host name as a suffix, like postgres-<host> or traefik-<host>. I run several servers on the same private network, and a log line or a docker ps from the wrong terminal is obvious at a glance. App containers keep the default names Compose gives them.
Pin what hurts to upgrade
Databases get a major version tag (postgres:18-alpine), because an unplanned major upgrade of a database is a ruined afternoon. The proxy is pinned to a major version too. Small stateless tools can follow latest. I accept the occasional surprise there in exchange for not babysitting tags.
If you’re starting out
Make one external network for the proxy and one for data. Bind-mount data into the stack’s folder. Pass environment by name. Name containers so you can tell hosts apart. Healthchecks, resource limits, and log rotation can come later, the first time their absence costs you something. You’ll remember why you added them.