Docker Compose is the default orchestration tool for most homelab setups. It’s not Kubernetes, but it doesn’t need to be — a well-structured Compose file with proper environment management, networking, and health checks will serve a single-host stack for years without drama.
This post covers the patterns I use across my Proxmox Docker hosts. These aren’t theoretical — they’re what’s running right now on the homelab.
Directory Layout
Every service gets its own directory with a consistent structure:
/opt/docker/
├── frigate/
│ ├── compose.yml
│ ├── .env
│ ├── config/
│ │ ├── config.yml
│ │ └── mosquitto/
│ └── storage/
├── cloudflared/
│ ├── compose.yml
│ └── .env
├── uptime-kuma/
│ ├── compose.yml
│ ├── .env
│ └── data/
├── nginx/
│ ├── compose.yml
│ ├── .env
│ ├── conf.d/
│ └── certs/
└── shared/
└── .env (common env vars)
Each stack is self-contained. No monorepo-style mega docker-compose.yml with 20 services — you lose the ability to restart services independently and the file becomes unreadable.
Rule: One compose file per domain. Frigate is its own compose. Nginx proxy is its own compose. Monitoring stack is its own compose. If two services genuinely depend on each other (e.g., Grafana + Prometheus + Loki), bundle them in one compose.
Compose File Template
|
|
Key points:
container_name— explicit naming avoids confusion whendocker psshows 10 containersrestart: unless-stopped— recovers from crashes and host reboots, but respects manualdocker stopvolumesalways use relative paths (./data) — the compose file is the source of truth for data locationsnetworks: internal: trueby default — services don’t need internet access unless you add an external networkhealthcheck— critical for depend-on conditions and monitoringloggingwith rotation — prevents /var from filling up
Environment Management
The .env File
Docker Compose automatically loads .env from the same directory as the compose file. Use it for variable data — secrets, ports, versions:
|
|
The compose file references these with ${VAR} syntax:
|
|
Never commit .env to git. Add it to .gitignore. Instead, commit .env.example with placeholder values:
|
|
Shared .env Across Stacks
When multiple stacks need the same values (timezone, UID/GID, internal domain), use a shared .env:
|
|
Reference it in each compose file with env_file:
|
|
The shared .env defines common defaults. Each stack’s local .env overrides or adds secrets.
Networking Patterns
Internal Bridge (Default)
Most services don’t need direct network access. Use an internal bridge:
|
|
Services can talk to each other by service name (Compose DNS resolution), but nothing from outside the Docker network can reach them — including other Docker containers on the same host.
External Network for Inter-Stack Communication
When service A needs to talk to service B across different compose stacks (e.g., Frigate → MQTT), create a shared external network:
|
|
|
|
|
|
Now Frigate can reach Mosquitto by service name (mosquitto) even though they’re in different compose stacks, as long as they’re on the same Docker host.
MACVLAN for Direct VLAN Access
For services that need to live on a specific physical VLAN (e.g., Frigate on VLAN 50 for CCTV, or a reverse proxy on the same subnet as your LAN), use a MACVLAN network:
|
|
The container gets its own MAC and IP on VLAN 50. It looks like a physical device to the rest of the network. This is how Frigate connects directly to cameras without NAT.
Caveat: MACVLAN doesn’t allow host-to-container communication unless you add a second network or use ipvlan. The container can reach the network but the Docker host can’t reach it via the MACVLAN IP. Use a second internal network for host-to-container access if needed.
Host Network Mode (Use Sparingly)
|
|
The container shares the host’s network stack — no bridge, no port mapping. Useful for DNS servers (need port 53 on host), but avoid it for most services. It breaks Compose DNS resolution and isolation.
Health Checks and Dependencies
Health Checks
Every service that exposes an HTTP endpoint should have a health check:
|
|
For services without HTTP (databases):
|
|
depends_on
Use condition: service_healthy for hard dependencies:
|
|
This is better than a raw depends_on (which only waits for the container to start, not for the service inside it to be ready). Compose v2.20+ supports this natively.
Secrets and Configuration
Avoid Environment Variables for Secrets
Environment variables leak into docker inspect, logs, and child processes. For sensitive values (API keys, database passwords):
Option 1: Docker Secrets (Swarm mode only)
|
|
The secret is mounted as a file at /run/secrets/db_password inside the container. Other processes can’t read it via /proc.
Option 2: File-based Config
|
|
Read-only bind mounts for config files are the simplest approach. The config file lives outside the image — you edit it, restart the service, it picks up the changes.
Option 3: .env (acceptable for local homelab)
For a single-user homelab where the threat model is “internet randos, not physical attackers,” .env files are fine. Just don’t commit them.
Config File Management
Keep configuration in the compose directory, organized by service:
/opt/docker/frigate/
├── compose.yml
├── .env
├── config/
│ ├── config.yml
│ ├── mosquitto/
│ │ └── mosquitto.conf
│ └── pwfile
└── storage/
├── recordings/
└── clips/
The compose file mounts these as read-only when possible:
|
|
Read-only mounts prevent the container from modifying its own config — a common source of “why did my config change” confusion.
Volume Management
Named Volumes vs Bind Mounts
| Aspect | Named Volume | Bind Mount |
|---|---|---|
| Managed by | Docker | User |
| Location | /var/lib/docker/volumes/ |
Anywhere |
| Backup | Needs extra step | Straight file copy |
| Permissions | Docker handles them | Host permissions apply |
| Sharing across hosts | Needs volume driver | NFS/Samba works |
Use bind mounts for configuration (you edit config files directly). Use named volumes for databases and state (easier to manage, Docker handles permissions):
|
|
Backing Up Bind Mounts
Since bind mounts are just directories on the host, backup is a simple rsync or ZFS snapshot:
|
|
For databases with named volumes, use docker run --volumes-from or pg_dump:
|
|
Logging
Default Docker logging writes to a JSON file per container without rotation — it will fill your disk given enough time.
Per-Service Logging
|
|
This keeps at most 30 MB of logs per service — 3 files × 10 MB each. Tune max-size based on how chatty the service is. Frigate logs are verbose; 10m works. A DNS server might need 5m.
Centralized Logging (Optional)
For the serious homelab, forward logs to Loki + Grafana:
|
|
Requires the Loki Docker driver plugin. Overkill for a single-host lab, but nice if you have multiple hosts.
Version Pinning
Always pin major/minor versions. :latest will break your stack at 3 AM when the upstream maintainer changes something:
|
|
Use Renovate or Dependabot to get PRs when updates are available — review and update deliberately instead of waking up to a broken service.
For immich and similar fast-moving projects, pin to a specific release (e.g., immich/server:v1.120.0). For stable projects like Nginx or Postgres, the minor version tag (nginx:1.27) is sufficient.
Backup Strategy for Compose Stacks
The Three-Layer Approach
Layer 1: Compose files + config → git repo
Layer 2: Persistent data → ZFS snapshots + rsync
Layer 3: Database dumps → cron + rclone
Layer 1 is the compose definition itself. Push to a private git repo:
|
|
.env files with secrets stay in .gitignore. Everything else is versioned.
Layer 2 uses ZFS snapshots on the dataset hosting /opt/docker:
|
|
Sanoid handles automatic rotation (covered in the ZFS post).
Layer 3 is a nightly cron that dumps databases and syncs to offsite storage:
|
|
Resource Limits
Don’t let one noisy container starve the rest. Set resource limits:
|
|
In Compose v2, deploy.resources works in standalone mode (not just Swarm). The container won’t exceed 2 CPU cores or 2 GB RAM.
For simpler setups, use Docker run flags mapped in compose:
|
|
These are Docker Compose v2 native keys. Pick one style and stick with it.
Real Stack — Monitoring
Here’s a complete monitoring stack demonstrating these patterns:
|
|
This stack uses:
- Environment files for TZ
- Docker secrets for the Grafana admin password
- Read-only config mounts for Loki, Prometheus, and Grafana provisioning
- Named volumes for database/state (Grafana, Loki, Prometheus)
- Health checks on Loki and Uptime Kuma
- Resource limits (would add them on a constrained host)
- Log rotation on every service
- Host network mode for node-exporter (needs host metrics)
Compose File Checklist
Before deploying a new stack:
-
container_nameis set (don’t rely on auto-generated names) -
restart: unless-stopped(oralwaysfor essential infrastructure) - Image tag is pinned (no
:latestwithout deliberate reasoning) -
.envfile exists and is in.gitignore -
volumesuse relative paths (./data) or named volumes - Config files are mounted
:rowhere possible -
healthcheckis defined for service dependencies -
logginghas rotation limits (max-size,max-file) - Ports are only exposed when necessary (not publishing every port)
- Network is
internal: trueunless external access is required - Secrets are in files, not env vars (or at least in
.env, not compose)
Summary
| Pattern | When to Use |
|---|---|
| One compose per domain | Always — keeps files readable and manageable |
.env for config |
Every stack — separates config from compose |
| External networks | Cross-stack communication (Frigate→MQTT, Grafana→Loki) |
| MACVLAN | Services on a specific physical VLAN (cameras, proxy) |
| Health checks | Services others depend on (databases, APIs) |
| Read-only mounts | Config files — prevents accidental modification |
| Named volumes | Database state, application data |
| Image pinning | Always — :latest will bite you |
| Resource limits | Constrained hosts or noisy neighbors |
| Git for compose files | Version control your infrastructure |
Docker Compose won’t scale to a 100-node cluster, but for a single-host homelab it’s the sweet spot between complexity and control. A well-structured compose file with proper env management and health checks will run reliably for years — and when you need to rebuild, it’s docker compose up -d and you’re back.