How I lay out Docker Compose stacks on one server

October 2026 Luis Vera

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.

Share: