If you’ve been self-hosting for more than a week, you’ve already written a docker-compose.yml file. Maybe you copied one from a GitHub README, fired it up, and it worked. Magic.
Then six months passed. You have 15 services, four compose files scattered across your home directory, containers with restart: always that silently fail, hardcoded passwords in plain text, and a vague sense of dread whenever you ssh into your server.
Sound familiar? This guide is for you.
These are the Docker Compose best practices that will make your self-hosted stack actually maintainable β whether you’re running two containers or twenty.
1. Use a Consistent Directory Structure
Before touching a single YAML key, get your folder layout right. A predictable structure means you can find anything in seconds and reason about your stack without digging through logs.
/opt/stacks/
βββ nextcloud/
β βββ docker-compose.yml
β βββ .env
β βββ data/ # persistent volumes (optional)
βββ jellyfin/
β βββ docker-compose.yml
β βββ .env
βββ traefik/
β βββ docker-compose.yml
β βββ .env
β βββ config/
β βββ traefik.yml
βββ monitoring/
βββ docker-compose.yml
βββ .env
Each service or logical group gets its own folder. Keep docker-compose.yml and .env side-by-side. Mount data into clearly named subdirectories or use named Docker volumes (more on that below).
Why it matters: When something breaks at 2 AM, you want to run cd /opt/stacks/nextcloud && docker compose logs β not hunt for where you put things.
2. Never Hardcode Secrets in Compose Files
This is the most important habit to build. Your docker-compose.yml is likely committed to Git, shared in forums, or accidentally shown on a stream. Hardcoded passwords don’t stay private.
Bad:
| |
Good β use a .env file:
| |
And in your .env file (same directory, never committed to Git):
| |
Add .env to your .gitignore:
.env
*.env
For an extra layer, use Docker secrets for truly sensitive values (database passwords, API keys):
| |
Store ./secrets/db_password.txt with tight permissions:
| |
Not every image supports _FILE variants, but PostgreSQL, MariaDB, and many popular images do. Check the image docs.
3. Always Pin Image Tags
image: nginx:latest is a timebomb. The next time you docker compose pull && docker compose up -d, you might get a breaking change. For self-hosted stacks, stability beats novelty.
Bad:
| |
Good:
| |
When you want to update, do it deliberately: check the changelog, update the tag, test, and roll forward.
Practical middle ground: Use a digest pin for absolute reproducibility, or use major/minor tags when the project guarantees semantic versioning. For linuxserver.io images, their versioned tags are stable and well-maintained.
If you’re using Watchtower for automated updates, that’s a separate conversation β but even then, know what you’re automatically updating.
4. Define Explicit Networks
By default, Docker Compose creates a network per project and connects all services to it. That’s fine for simple stacks, but for a multi-service homelab it creates implicit dependencies and makes your security posture fuzzy.
Be explicit. Create named networks and only attach services to the networks they need:
| |
For services that need to connect across compose stacks (e.g., Traefik reaching app containers), use external networks:
| |
Create it once on the host:
| |
Now all your stacks can attach to this shared network, and Traefik can route to them without each stack needing to know about each other’s internals.
5. Set Resource Limits
Runaway containers can saturate your server. Set memory and CPU limits so one misbehaving service can’t take down everything else:
| |
Guidelines for a typical homelab:
- Databases (PostgreSQL, MariaDB): 512MBβ1GB memory limit
- Media servers (Jellyfin, Plex): limit CPU to avoid transcoding eating everything
- Web apps: 256MBβ512MB is usually plenty
- Background workers: low CPU reservation, burst as needed
If you’re on a resource-constrained machine (like a Raspberry Pi or a small mini PC), these limits are even more important. A single container stuck in a memory leak won’t crash the whole machine.
6. Add Health Checks
Docker can tell you a container is “running,” but that just means the process started. Health checks let Docker know if the service inside is actually working, enabling proper dependency ordering and automatic restarts.
| |
Without condition: service_healthy, depends_on only waits for the container to start β not for the service inside to be ready. This is the cause of a huge number of “my app can’t connect to the database on startup” issues.
Common health check patterns:
| |
7. Use Named Volumes for Persistent Data
Bind mounts (./data:/var/lib/data) work, but named Docker volumes are cleaner for databases and application state:
| |
Named volumes vs bind mounts β when to use which:
| Use case | Recommendation |
|---|---|
| Database data | Named volume |
| App config files (you edit them) | Bind mount |
| Media files (photos, videos) | Bind mount to a specific path |
| Log files | Named volume or bind mount |
| TLS certificates | Bind mount (you manage them) |
Inspect and back up named volumes:
| |
8. Configure Proper Logging
By default, Docker uses the json-file driver with no size limit. Left unchecked, container logs will fill your disk.
Set a global default in /etc/docker/daemon.json:
| |
Restart Docker: sudo systemctl restart docker
Or set it per service in your compose file:
| |
If you’re running a centralized log stack (Loki, Graylog), you can switch the driver:
| |
9. A Complete Example: Nextcloud Stack Done Right
Here’s what all these best practices look like combined in a real-world Nextcloud setup:
| |
And the corresponding .env:
| |
Secrets files in ./secrets/ with chmod 600, and .env in .gitignore. Clean, reproducible, maintainable.
10. Quick Reference: Restart Policies
| Policy | Behavior | When to use |
|---|---|---|
no | Never restart | Dev/testing only |
always | Always restart (even on manual stop) | System services |
unless-stopped | Restart unless manually stopped | Most services |
on-failure | Only restart on non-zero exit | Batch jobs, init containers |
Use unless-stopped as your default. It survives server reboots but won’t fight you when you run docker compose down intentionally.
Wrapping Up
The difference between a fragile homelab and a rock-solid one usually comes down to discipline in the compose files: no hardcoded secrets, explicit networks, pinned tags, health checks, and resource limits.
None of these practices are difficult β they’re just habits. The next time you spin up a new service, run through this checklist:
- Secrets in
.envor Docker secrets, not in YAML - Image tag pinned
- Explicit networks defined
- Health checks configured
-
depends_onwithcondition: service_healthywhere needed - Resource limits set
- Log rotation configured
-
restart: unless-stopped
Your future self β at 2 AM, staring at a broken stack β will thank you.
Want to go deeper? Our upcoming guides on Traefik reverse proxy setup and automated Docker updates with Watchtower will cover these topics in detail β stay tuned.