How to Organize Multiple Docker Compose Projects Without Losing Your Mind

The Problem: Compose File Sprawl

It starts innocently. One docker-compose.yml for Pi-hole. Another for FreshRSS. Then one for a media server, a dashboard, a wiki, a bot. Before long, you’ve got a dozen YAML files scattered across half a dozen directories, and you can’t remember which project depends on which network.

There’s no single “right” way to organize Docker Compose projects, but after years of iteration, I’ve landed on a structure that keeps things maintainable without over-engineering. Let me walk you through it.

Prerequisites

  • Docker Engine 20.10+ and Docker Compose V2 (docker compose, not docker-compose)
  • A Linux server with your containers already running (or about to be)
  • Basic familiarity with Docker and YAML syntax
  • About 30 minutes to reorganize your existing setup

Step 1: Pick a Root Directory

Choose one parent directory for all your self-hosted services. I use /opt/containers/. Some people prefer /home/youruser/docker/ or /srv/. It doesn’t matter which — what matters is that you commit to it.

sudo mkdir -p /opt/containers
sudo chown $USER:$USER /opt/containers

Each service gets its own subdirectory with a single docker-compose.yml:

/opt/containers/
├── pihole/
│   └── docker-compose.yml
├── freshrss/
│   └── docker-compose.yml
├── homer/
│   └── docker-compose.yml
├── monitoring/
│   ├── docker-compose.yml
│   ├── prometheus/
│   │   └── prometheus.yml
│   └── grafana/
│       └── provisioning/
└── shared-network.yml

Step 2: Create a Shared External Network

Here’s the key insight: instead of letting each Compose file create its own isolated network, define one external network that all services can join. This lets containers talk to each other by service name.

Create a dedicated “router” project — something like Traefik, Nginx Proxy Manager, or Caddy — that owns the shared network:

# /opt/containers/proxy/docker-compose.yml
version: "3.8"

services:
  traefik:
    image: traefik:v3
    container_name: traefik
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./config:/etc/traefik
    networks:
      - shared
    restart: unless-stopped

networks:
  shared:
    name: shared
    external: false

Then in every other service, mark the network as external: true:

# /opt/containers/pihole/docker-compose.yml
version: "3.8"

services:
  pihole:
    image: pihole/pihole:latest
    container_name: pihole
    environment:
      TZ: "America/New_York"
      WEBPASSWORD: "your-password-here"
    volumes:
      - ./etc-pihole:/etc/pihole
      - etc-dnsmasq:/etc/dnsmasq.d
    networks:
      - shared
    restart: unless-stopped

networks:
  shared:
    external: true

Why this matters: now your Homer dashboard can reach Pi-hole at http://pihole:80 instead of juggling IP addresses or host networking.

Step 3: Use .env Files Per Project

Each project directory should have its own .env file for secrets and configuration. This keeps sensitive values out of your Compose files and makes backups safer.

# /opt/containers/pihole/.env
TZ=America/New_York
WEBPASSWORD=supersecretpassword
PIHOLE_DNS_=1.1.1.1

Then reference them in your Compose file:

environment:
  TZ: ${TZ}
  WEBPASSWORD: ${WEBPASSWORD}

Pro tip: Add .env to your .gitignore if you version-control your Compose files. Never commit passwords to git.

Step 4: Create a Master Management Script

Once you have 10+ services, SSHing into your server and cd-ing into each directory gets tedious. Create a simple shell script at the root:

#!/bin/bash
# /opt/containers/manage.sh

PROJECT=$1
ACTION=$2

if [ -z "$PROJECT" ]; then
  echo "Usage: ./manage.sh <project> <up|down|restart|logs|pull>"
  echo "Projects:"
  for d in */; do
    if [ -f "$d/docker-compose.yml" ]; then
      echo "  - ${d%/}"
    fi
  done
  exit 0
fi

if [ ! -f "$PROJECT/docker-compose.yml" ]; then
  echo "No docker-compose.yml found in $PROJECT/"
  exit 1
fi

cd "$PROJECT" || exit 1

case $ACTION in
  up)      docker compose up -d ;;
  down)    docker compose down ;;
  restart) docker compose restart ;;
  logs)    docker compose logs -f ;;
  pull)    docker compose pull && docker compose up -d ;;
  *)       echo "Unknown action: $ACTION" ;;
esac

Make it executable:

chmod +x /opt/containers/manage.sh

Now you can manage any service from anywhere on the server:

./manage.sh pihole restart
./manage.sh freshrss logs
./manage.sh homer pull

Step 5: Set Up Automated Updates with Watchtower

If you’re not already auto-updating your containers, add Watchtower as one of your core services:

# /opt/containers/watchtower/docker-compose.yml
version: "3.8"

services:
  watchtower:
    image: containrrr/watchtower
    container_name: watchtower
    environment:
      WATCHTOWER_CLEANUP: "true"
      WATCHTOWER_POLL_INTERVAL: 86400
      WATCHTOWER_LABEL_ENABLE: "true"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    networks:
      - shared
    restart: unless-stopped

networks:
  shared:
    external: true

With WATCHTOWER_LABEL_ENABLE: "true", you can choose which containers get auto-updated by adding labels:

labels:
  - "com.centurylinklabs.watchtower.enable=true"

For services that might break on update (looking at you, database containers), disable them explicitly:

labels:
  - "com.centurylinklabs.watchtower.enable=false"

Step 6: Back Up Your Compose Files (Not Your Data)

Your Compose files and configs are more valuable than the containers themselves. Containers are ephemeral — you can recreate them. But that carefully tuned Traefik config? That took you three hours.

Version control everything (except secrets):

cd /opt/containers
git init
echo ".env" >> .gitignore
echo "*.log" >> .gitignore
git add .
git commit -m "Initial container configuration"
git remote add origin [email protected]:you/selfhosted-infra.git
git push -u origin main

For data volumes, use a separate backup strategy — rsync, restic, or borg — targeting the bind-mount directories.

Common Pitfalls

1. “Network shared not found”

This happens when a service tries to join the shared network before the proxy container has created it. Always start the proxy first:

cd /opt/containers/proxy && docker compose up -d
# Wait a few seconds, then start everything else

2. Port Conflicts

If two services both want port 80, only the proxy should expose ports. Let everything else communicate over the shared network internally.

3. YAML Indentation Errors

YAML is whitespace-sensitive. Use 2 spaces (never tabs), and validate your files with:

docker compose -f /path/to/docker-compose.yml config

This parses the file without starting anything and catches syntax errors immediately.

4. Forgetting container_name

Without explicit container names, Docker assigns random ones. This makes debugging harder. Always set container_name to something memorable.

The Bigger Picture

Self-hosting isn’t about running a perfect Kubernetes cluster in your basement. It’s about understanding your own infrastructure deeply enough that you can fix it at 2 AM with a hangry expression and a cup of cold coffee.

A clean file structure won’t make you a better sysadmin overnight. But it will mean that when you inevitably come back to a project six months later, you’ll know exactly where everything lives and why you made the choices you did.

That’s the real gift of good organization: future-you thanking present-you.


Got a self-hosting question or a better way to organize your containers? Drop a comment below or find me on the usual platforms. I’d love to hear how you structure your setup.

Leave a Reply

Your email address will not be published. Required fields are marked *