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, notdocker-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