Docker Compose Best Practices: 12 Rules I Follow After Running 40+ Containers
12 battle-tested Docker Compose best practices after running 40+ containers. Real configs, resource limits, health checks, and security hardening.
Author
Marcus Chen
Key Takeaways
- Use named volumes over bind mounts for anything that stores data you care about
- Always set resource limits - one rogue container can freeze your entire homelab
- Health checks are not optional - they're the difference between "running" and "actually working"
- Use .env files for all configuration that changes between environments
- Network segmentation prevents a compromised container from reaching everything
- Read-only containers and no-new-privileges should be your default, not your exception
After running 40+ Docker containers across three Proxmox hosts for the better part of four years, I've developed a set of rules for Docker Compose files. These aren't theoretical best practices I read in a blog post - they're patterns I arrived at after breaking things spectacularly at 2 AM and swearing "never again."
Here's the thing nobody tells you about Docker Compose: the syntax is trivial. Any junior admin can write a working compose file in five minutes. Writing one that survives a power outage, a runaway process, and a disk full notification at 3 AM without losing data - that takes a bit more thought.
This guide covers the 12 rules I follow for every compose file in my homelab. If you're already comfortable with Docker basics (if not, start with my Docker for Homelabs guide), these practices will save you from the mistakes I've already made.
1. Use Named Volumes, Not Bind Mounts (For Data You Care About)
This is the hill I will die on. Bind mounts (mapping a host path like /home/user/data:/data) are fine for development. For anything running in production on your homelab, use named volumes.
Why? Bind mounts have no lifecycle management. You can't back them up through Docker. You can't move them between hosts easily. And when you accidentally rm -rf the parent directory at midnight (don't ask), your data is gone.
Named volumes are managed by Docker. You can inspect them, back them up with docker run --rm -v myvolume:/data -v $(pwd):/backup alpine tar czf /backup/myvolume.tar.gz /data, and they persist even if you remove the container.
# Good - named volume
services:
postgres:
image: postgres:16
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
# Fine for dev, risky for production - bind mount
services:
postgres:
image: postgres:16
volumes:
- ./data/postgres:/var/lib/postgresql/data
One exception: configuration files. I use bind mounts for config files that I edit frequently on the host. For example, Nginx configs or Prometheus rules. These are version-controlled anyway, so losing them isn't catastrophic.
2. Always Set Resource Limits
Every container in my homelab has memory and CPU limits. No exceptions. Here's why: I once had a Jellyfin transcode job eat all 32GB of RAM on a host, freezing every other container including my DNS server. The entire network went dark because one container was greedy.
Resource limits prevent this. A container hit its memory limit gets OOM-killed - painful for that one service, but the rest of your infrastructure stays alive.
services:
jellyfin:
image: jellyfin/jellyfin:latest
deploy:
resources:
limits:
memory: 4G
cpus: '2.0'
reservations:
memory: 1G
cpus: '0.5'
My general rule of thumb for a homelab:
- Web apps (Nextcloud, Gitea, etc.): 512MB-1GB limit
- Databases (PostgreSQL, MariaDB): 1-2GB limit
- Media servers (Jellyfin, Plex): 2-4GB limit
- Monitoring (Prometheus, Grafana): 1-2GB limit
- Lightweight utilities (Uptime Kuma, Pi-hole): 256MB limit
The reservations field is equally important - it tells Docker to guarantee that minimum amount of resources for the container. Without it, a busy container might get starved by its neighbors.
3. Health Checks Are Not Optional
I used to skip health checks. "The container is running, so it's fine." Wrong. A container can be running and completely broken - the process is alive but the application is hung, the database connection pool is exhausted, or the disk is full.
A health check tells Docker (and your monitoring stack) whether the application inside the container is actually working, not just whether the process exists.
services:
postgres:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
nextcloud:
image: nextcloud:latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/status.php"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
The start_period is critical. It tells Docker to not count failures during startup against the retry counter. Without it, a slow-starting container gets killed before it has a chance to initialize.
Pair health checks with depends_on conditions to control startup order:
services:
app:
image: myapp:latest
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
This way, app won't start until PostgreSQL is actually ready - not just "started."
4. Use .env Files for Configuration
Hardcoding values in your compose file is a recipe for trouble. When you want to move from your test server to production, or share the file with someone, you're stuck doing find-and-replace.
Instead, use environment variables with a .env file:
# docker-compose.yml
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- ${DB_VOLUME:-postgres_data}:/var/lib/postgresql/data
# .env
DB_USER=homelab
DB_PASSWORD=s3cur3-p@ssw0rd
DB_NAME=nextcloud
DB_VOLUME=nextcloud_db
A few rules for .env files:
- Never commit
.envto git. Add it to.gitignore. Use.env.examplewith placeholder values instead. - Use defaults for non-sensitive values:
${DB_VOLUME:-postgres_data}usespostgres_dataif the variable isn't set. - One
.envper environment. I have.env.production,.env.staging, and.env.devfor services that run in multiple environments.
5. Network Segmentation: Don't Put Everything on One Network
By default, Docker Compose creates a single network for all services in a compose file. Every container can talk to every other container. This is fine for a simple stack, but dangerous for anything exposed to the internet.
I create separate networks for different trust levels:
services:
nginx:
image: nginx:alpine
networks:
- frontend
- backend
app:
image: myapp:latest
networks:
- backend
postgres:
image: postgres:16
networks:
- backend
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # No external access
The internal: true flag on the backend network means containers on that network can't reach the internet. Only the nginx proxy (which sits on both networks) can talk to the backend services. If someone compromises your web app, they can't use it to exfiltrate data to an external server.
For a homelab, I use three network tiers:
- frontend: Services exposed through a reverse proxy (nginx, Traefik, Caddy)
- backend: Internal services and databases
- monitoring: Prometheus, Grafana, alertmanager
6. Set Restart Policies
Your homelab server reboots for kernel updates. Your containers should come back automatically.
services:
nextcloud:
image: nextcloud:latest
restart: unless-stopped
The four options:
no: Never restart (default - and the worst default ever)always: Restart always, including on manual stopon-failure: Only restart on non-zero exit codeunless-stopped: Restart unless manually stopped
I use unless-stopped for everything. It handles reboots gracefully but lets me intentionally stop a container for maintenance without Docker stubbornly restarting it.
One gotcha: restart: always combined with a container that crashes on startup creates an infinite restart loop that fills your logs and burns CPU. Always test your compose file before setting restart policies.
7. Use Profiles for Optional Services
Docker Compose profiles let you selectively start services. This is perfect for optional services you don't always need.
services:
app:
image: myapp:latest
restart: unless-stopped
postgres:
image: postgres:16
restart: unless-stopped
debug-tools:
image: busybox
profiles:
- debug
command: sleep infinity
adminer:
image: adminer
profiles:
- admin
ports:
- "8080:8080"
# Start only core services
docker compose up -d
# Start with debug tools
docker compose --profile debug up -d
# Start with admin panel
docker compose --profile admin up -d
I use this for development tools (adminer, pgadmin, portainer) that I only need when troubleshooting. They don't run by default, but I can spin them up instantly when needed.
8. Logging: Don't Let Logs Eat Your Disk
Docker's default logging driver writes JSON to /var/lib/docker/containers/. If a container produces verbose logs (and they all do eventually), this directory will grow until your disk is full. And when your disk is full, Docker stops. Everything stops.
Set log rotation globally in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
Or per-service in your compose file:
services:
app:
image: myapp:latest
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
This keeps at most 30MB of logs per container (3 files x 10MB each). Older logs are automatically rotated out.
For my monitoring stack, I use a different approach - I ship logs to Loki with the Loki Docker driver plugin:
services:
app:
image: myapp:latest
logging:
driver: loki
options:
loki-url: "http://loki:3100/loki/api/v1/push"
This gives me centralized log search through Grafana without filling up local disk.
9. Pin Your Image Versions
image: postgres:latest is a ticking time bomb. One day, latest is PostgreSQL 15. The next time you pull, it's PostgreSQL 16 with breaking changes and your database won't start.
Always pin to a specific version:
services:
postgres:
image: postgres:16.4-alpine
My versioning strategy:
- Production databases: Pin to minor version (
postgres:16.4-alpine) - Web applications: Pin to major version (
nextcloud:29) - Utilities: Pin to major version (
nginx:1.27-alpine) - Development only:
latestis fine
The -alpine suffix is worth using when available. Alpine-based images are typically 5-10x smaller than their Debian equivalents, which means faster pulls, less disk usage, and a smaller attack surface.
10. Security: Read-Only Containers and No New Privileges
Most containers don't need to write to their own filesystem. Making them read-only prevents an attacker who compromises the container from modifying binaries, installing tools, or writing malware.
services:
nginx:
image: nginx:alpine
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
- /tmp
security_opt:
- no-new-privileges:true
The tmpfs mounts give the container writable directories where it actually needs them (temp files, caches, PID files) without making the entire filesystem writable.
no-new-privileges:true prevents a process inside the container from gaining more privileges than its parent. This blocks privilege escalation attacks.
Not every container works with read_only: true. Some applications write to unexpected locations. Start with it enabled, check the logs for permission errors, and add specific tmpfs mounts as needed.
For containers that need to run as root (like some database images), drop capabilities you don't need:
services:
postgres:
image: postgres:16
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
- FOWNER
This follows the principle of least privilege - the container only gets the specific Linux capabilities it needs, not all of them.
11. Organize Your Compose Files by Stack
I used to have one massive docker-compose.yml with 30 services. It was a nightmare to maintain. Now I organize by logical stack:
homelab/
├── media/
│ ├── docker-compose.yml # Jellyfin, Radarr, Sonarr, Prowlarr
│ └── .env
├── monitoring/
│ ├── docker-compose.yml # Prometheus, Grafana, Alertmanager, Loki
│ └── .env
├── nextcloud/
│ ├── docker-compose.yml # Nextcloud, PostgreSQL, Redis
│ └── .env
├── networking/
│ ├── docker-compose.yml # Pi-hole, Unbound, WireGuard
│ └── .env
└── reverse-proxy/
├── docker-compose.yml # Nginx Proxy Manager or Caddy
└── .env
Each stack has its own compose file, its own .env, and its own network. I can update, restart, or troubleshoot one stack without affecting the others.
If containers across stacks need to communicate (like a monitoring agent in the media stack talking to Prometheus), use external networks:
# monitoring/docker-compose.yml
networks:
monitoring:
driver: bridge
# media/docker-compose.yml
networks:
monitoring:
external: true
12. Use Multi-Stage Builds for Custom Images
When you need to build your own images (and you will eventually), multi-stage builds keep them small and secure.
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/index.js"]
The build stage has all the development dependencies (compilers, build tools, dev packages). The final image only contains the compiled application and production dependencies. This can reduce image size by 70-90%.
In your compose file, reference the build context:
services:
myapp:
build:
context: ./myapp
dockerfile: Dockerfile
target: production # Use a specific build stage
image: myapp:latest
Recommended Hardware for Your Docker Homelab
Running 40+ containers doesn't require enterprise hardware. But it does require reliable hardware. Here's what I recommend:
Complete Example: A Production-Ready Compose File
Here's a real-world example that combines all 12 practices. This is a simplified version of my Nextcloud stack:
# nextcloud/docker-compose.yml
services:
nextcloud:
image: nextcloud:29-fpm-alpine
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
- /var/www/html/config
- /var/www/html/data
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
- FOWNER
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
- POSTGRES_HOST=postgres
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=redis
volumes:
- nextcloud_data:/var/www/html
- ./php-uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
networks:
- frontend
- backend
deploy:
resources:
limits:
memory: 2G
cpus: '1.0'
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/status.php"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
- /tmp
security_opt:
- no-new-privileges:true
depends_on:
nextcloud:
condition: service_healthy
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- nextcloud_data:/var/www/html:ro
ports:
- "8080:80"
networks:
- frontend
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
postgres:
image: postgres:16.4-alpine
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
- FOWNER
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend
deploy:
resources:
limits:
memory: 1G
cpus: '0.5'
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
redis:
image: redis:7-alpine
restart: unless-stopped
read_only: true
security_opt:
- no-new-privileges:true
volumes:
- redis_data:/data
networks:
- backend
deploy:
resources:
limits:
memory: 256M
cpus: '0.25'
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
nextcloud_data:
postgres_data:
redis_data:
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true
Every rule from this guide is applied here. Named volumes, resource limits, health checks, read-only containers, security options, restart policies, logging limits, network segmentation, and pinned image versions.
Common Mistakes I See (And Have Made)
Running everything as root. Many Docker images default to root. Check if the image supports a non-root user (most do) and setUSER in your Dockerfile or user: in your compose file.
Not backing up volumes. Named volumes are great, but they're not backups. Set up automated volume backups - I wrote about my backup strategy if you need a starting point.
Ignoring disk space. Docker images, volumes, and logs accumulate fast. Run docker system prune -a --volumes periodically (but understand what it deletes first). Monitor disk usage with something like Uptime Kuma.
Using docker-compose (hyphenated). Docker Compose V1 (docker-compose) is deprecated. Use docker compose (space, no hyphen) with Docker Compose V2. It's faster, has better error messages, and is actively maintained.
One compose file to rule them all. Split by logical stack. You'll thank yourself when you need to restart just the monitoring stack without taking down your media server.
Not using Docker Compose watch mode. Compose V2 introduced a watch mode that automatically rebuilds and restarts containers when source files change. It's fantastic for development:
docker compose watch
Configure it in your compose file:
services:
app:
build: .
develop:
watch:
- action: rebuild
path: ./package.json
- action: sync
target: /app/src
path: ./src
This syncs source code changes into the container without a full rebuild, and triggers a rebuild when dependencies change. It's the hot-reload experience you get with Docker Desktop, available in any environment.
Forgetting to clean up dangling images. Everydocker compose build creates new images. The old ones become "dangling" - invisible but still consuming disk space. Set up a cron job to prune them:
# Add to crontab - runs every Sunday at 3 AM
0 3 * * 0 docker image prune -f >> /var/log/docker-prune.log 2>&1
I've seen homelab servers lose 50-100GB to forgotten build artifacts. A weekly prune keeps things tidy.
Bonus: My Pre-Flight Checklist
Before I deploy any new compose stack, I run through this checklist:
- All images are version-pinned - no
latesttags in production - Resource limits set on every service - memory at minimum, CPU if needed
- Health checks defined for all services that support them
- Restart policy set to
unless-stopped - Volumes are named - no bind mounts for persistent data
- Networks are segmented - frontend/backend separation at minimum
- Security options applied -
read_only,no-new-privileges, capability drops - Logging configured - max-size and max-file set
.envfile created - no hardcoded secrets in the compose file- Compose file in version control -
.envexcluded via.gitignore
This takes about five minutes per stack. It's saved me hours of debugging and at least one catastrophic data loss.
FAQ
Should I use Docker Compose in production?
Yes, with caveats. Docker Compose is perfectly fine for a homelab or a single-server deployment. If you're running multiple servers and need automatic failover, look at Docker Swarm or Kubernetes. But for most homelabs, Compose is the right tool - simple, declarative, and easy to back up.
How do I update containers without losing data?
Use docker compose pull to fetch new images, then docker compose up -d. Docker Compose only recreates containers whose images have changed. Named volumes persist across container recreations, so your data is safe. Always back up before major version upgrades.
What's the difference between depends_on and health checks?
depends_on without a condition only controls startup order - it doesn't wait for the service to be ready. depends_on with condition: service_healthy waits for the health check to pass. Always use the health check condition for services that need time to initialize (databases, APIs).
How do I share compose files between servers?
Keep your compose files in a Git repository. Use .env files for server-specific configuration (passwords, ports, paths). The .env file stays on each server and is excluded from Git via .gitignore.
How many containers can one server handle?
It depends on the workload, not the container count. I've run 40+ containers on a single Beelink SER5 with 32GB RAM. The bottleneck is usually RAM, then disk I/O for databases. Monitor resource usage and set limits accordingly.
What to Do Next
If you're setting up Docker Compose for the first time, start with three things: named volumes, restart policies, and resource limits. Get those right, and you've already avoided 80% of the problems I see in homelab forums.
Once you're comfortable, add health checks and network segmentation. These are the practices that separate a "working" setup from a "reliable" setup.
For more Docker content, check out my guides on Docker networking and choosing between VMs and containers. If you're planning your homelab network, the DNS guide covers the foundation every Docker host needs.

alt="Beelink SER5 MAX Mini PC"
alt="Crucial 32GB DDR5 RAM Kit"
alt="APC Back-UPS Pro BX1500M"