Docker Compose Best Practices for Homelab: A Complete Guide to Production-Grade Self-Hosting
Master Docker Compose for homelab: step-by-step guide covering security, version pinning, backups, and troubleshooting common pitfalls.
Introduction
Running a homelab is an exercise in controlled chaos. You start with a single container, then add a reverse proxy, a database, a monitoring stack, and before you know it, you have 15 services running from memory. The difference between a fragile setup and a resilient one often comes down to how you write your docker-compose.yml files. This guide is not about the basics—it's about the discipline that separates hobby projects from reliable infrastructure.
You will learn how to structure your Compose files for maintainability, pin versions correctly to avoid surprise breakage, handle secrets without leaking them into version control, and implement backup strategies that actually work. We will also cover advanced hardening techniques and a troubleshooting table for the most common failure modes. By the end, you will have a repeatable pattern for deploying any self-hosted application with confidence.
This guide assumes you are comfortable with the Linux command line and have basic Docker knowledge. We will focus on practical, copy-pasteable examples that you can adapt to your own stack immediately. All commands are written for Bash and are designed to be run sequentially.
Note on versions: The examples use the :latest tag where no specific version is verified. This is intentional—you should always check the official GitHub releases page before pinning a version. The version above may be outdated by now.
Prerequisites
Before you start, ensure your homelab host meets the following minimum requirements. These are typical values, not hard guarantees—your actual usage may vary depending on the number of containers and their workload.
| Component | Minimum | Recommended | Notes |
|---|---|---|---|
| CPU | 2 cores | 4+ cores | More cores help with parallel builds and database queries. |
| RAM | 4 GB | 8 GB+ | Each container typically uses 100-500 MB. Databases like PostgreSQL can use more. |
| Storage | 20 GB free | 100 GB+ SSD | SSDs drastically improve database and indexing performance. |
| OS | Ubuntu 22.04 LTS | Debian 12 | Any modern Linux distribution works. Ensure kernel supports cgroups v2. |
| Docker Engine | 24.0+ | 26.0+ | Older versions lack some Compose features. Check with docker --version. |
| Docker Compose Plugin | v2.20+ | v2.27+ | The standalone docker-compose binary is deprecated. Use the plugin. |
| Git | 2.30+ | Latest | For versioning your Compose files. |
Software to install:
sudo apt update && sudo apt upgrade -y && sudo apt install -y git curl nano
Install Docker Engine and Compose plugin:
curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER && newgrp docker
Verify the installation:
docker --version && docker compose version
Step-by-Step Installation Guide
Step 1: Create a Project Directory and .env File
Every service gets its own directory. This keeps configurations isolated and makes backups trivial. Start by creating a directory for your first service—we'll use paperless-ngx as an example.
mkdir -p ~/paperless-ngx && cd ~/paperless-ngx
Now create a .env file to store all secrets and configurable variables. Never commit this file to Git. It contains sensitive data.
nano .env
Add the following content, replacing the placeholder values with your own:
POSTGRES_PASSWORD=change_this_strong_password
PAPERLESS_SECRET_KEY=generate_a_random_64_char_string
PAPERLESS_TIME_ZONE=America/New_York
PAPERLESS_URL=https://docs.example.com
Generate a strong secret key with:
openssl rand -hex 32
Step 2: Write the Docker Compose File
Create a docker-compose.yml file in the same directory. This example includes PostgreSQL as the database and Redis for caching.
services:
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paperless"]
interval: 10s
timeout: 5s
retries: 5
broker:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redisdata:/data
app:
image: paperless-ngx:latest
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/usr/src/paperless/data
- ./media:/usr/src/paperless/media
- ./export:/usr/src/paperless/export
- ./consume:/usr/src/paperless/consume
env_file:
- .env
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db
depends_on:
db:
condition: service_healthy
broker:
condition: service_started
volumes:
pgdata:
redisdata:
Check the official GitHub releases page before pinning a version — the version above may be outdated by now.
Step 3: Pull Images and Start Services
Pull all images first to catch any authentication issues early.
docker compose pull
Then start the services in detached mode.
docker compose up -d
Check the status of all containers:
docker compose ps
Step 4: Set Up the Reverse Proxy (Traefik)
A reverse proxy routes traffic to your services based on domain names. Traefik integrates natively with Docker and automatically discovers new containers via labels. Create a separate directory for Traefik.
mkdir -p ~/traefik && cd ~/traefik
Create a docker-compose.yml for Traefik:
services:
traefik:
image: traefik:v3.1
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
- ./config:/etc/traefik
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=admin@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
Now add Traefik labels to your Paperless service. Edit ~/paperless-ngx/docker-compose.yml and add the following under the app service:
labels:
- "traefik.enable=true"
- "traefik.http.routers.paperless.rule=Host(`docs.example.com`)"
- "traefik.http.routers.paperless.entrypoints=websecure"
- "traefik.http.routers.paperless.tls.certresolver=letsencrypt"
Step 5: Configure Automatic Backups
Backups are non-negotiable. Use a separate container to back up your PostgreSQL database and files. Add a backup service to your Paperless Compose file:
backup:
image: prodrigestivill/postgres-backup-local:16
restart: unless-stopped
volumes:
- ./backups:/backups
environment:
POSTGRES_HOST: db
POSTGRES_DB: paperless
POSTGRES_USER: paperless
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
SCHEDULE: "@daily"
BACKUP_KEEP_DAYS: 7
depends_on:
db:
condition: service_healthy
For file backups, use a simple cron job on the host:
crontab -e
Add the following line to back up the data directory daily at 2 AM:
0 2 * * * tar -czf ~/backups/paperless-files-$(date +\%Y-\%m-\%d).tar.gz -C ~/paperless-ngx data media
Step 6: Implement Health Checks and Restart Policies
Every service should have a health check and a restart policy. We already added a health check for the database. For the app, add the following:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000"]
interval: 30s
timeout: 10s
retries: 3
Set restart: unless-stopped on all services, which we already did. This ensures containers restart automatically after a crash or a host reboot.
Step 7: Secure the Docker Socket
Exposing the Docker socket to containers is a security risk. If a container is compromised, the attacker gains root access to the host. Use the read_only and cap_drop options where possible. Add these to your app service:
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
Step 8: Set Up Log Rotation
Logs can fill up your disk quickly. Configure Docker's logging driver to rotate logs. Add this to each service:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
You can also set this globally in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
Restart Docker to apply:
sudo systemctl restart docker
Step 9: Create a Makefile for Common Tasks
Simplify your workflow with a Makefile in your project directory. Create it with:
nano Makefile
Add the following content:
.PHONY: up down logs ps pull backup
up:
docker compose up -d
down:
docker compose down
logs:
docker compose logs -f
ps:
docker compose ps
pull:
docker compose pull
backup:
docker compose exec db pg_dump -U paperless paperless > backup_$$(date +\%Y-\%m-\%d).sql
Now you can run make up, make logs, etc.
Step 10: Version Control Your Configurations
Initialize a Git repository to track changes to your Compose files. Do not commit the .env file.
cd ~/paperless-ngx && git init && git add docker-compose.yml Makefile && git commit -m "Initial commit"
Create a .gitignore file:
echo ".env" > .gitignore && echo "backups/" >> .gitignore && echo "data/" >> .gitignore
Step 11: Test Your Setup
Verify that all services are running and healthy:
docker compose ps
Check the logs for any errors:
docker compose logs app | tail -50
Visit https://docs.example.com in your browser to confirm the application is accessible through the reverse proxy.
Step 12: Monitor Resource Usage
Use htop or docker stats to monitor CPU and memory usage. This helps you identify which containers are resource hogs.
docker stats --no-stream
Advanced Configuration and Optimization
Reverse Proxy and SSL
We already covered Traefik above. If you prefer Nginx Proxy Manager, the configuration is similar. The key point is to always terminate SSL at the proxy and never expose container ports directly to the internet. Use internal networks in Docker to restrict access between containers.
Backup Strategy
A solid backup strategy has three layers: offsite, encrypted, and tested. Use restic or borgbackup to push encrypted backups to a remote server or cloud storage. The postgres-backup-local container we used handles the database dump. For files, consider using rclone to sync to a remote location.
Security Hardening (Optional)
The following settings are more aggressive and may break containers if applied blindly. Do not copy them verbatim—adapt them to each service.
| Setting | Effect | Risk |
|---|---|---|
read_only: true |
Makes the container filesystem read-only | Apps that write to their own directory will fail unless you mount tmpfs or volumes. |
cap_drop: [ALL] |
Removes all Linux capabilities | Some apps need specific capabilities like CHOWN or NET_ADMIN. Test thoroughly. |
security_opt: [no-new-privileges:true] |
Prevents privilege escalation | May break apps that need setuid binaries. |
pids_limit: 100 |
Limits the number of processes | Apps that fork many processes may crash. |
To check which user and group your container runs as, run:
docker compose exec app id
Run id -u && id -g on your host and verify against the image's documentation. The default user in the paperless-ngx image is paperless (UID 1000). Adjust file permissions accordingly.
Troubleshooting Common Issues
| Error | Cause | Solution |
|---|---|---|
Error response from daemon: driver failed programming external connectivity |
Port already in use | Run sudo lsof -i :8000 to find the process and stop it, or change the host port mapping. |
Container is unhealthy |
Health check failing | Check logs with docker compose logs <service>. The app may not be responding on the expected port. |
Permission denied when writing to volumes |
UID/GID mismatch | Run id -u && id -g on your host and compare with the container's user. Use chown -R $(id -u):$(id -g) ./data on the host. |
Could not resolve host: db |
Service name not available | Ensure the depends_on directive is correct and the database container is running. Check with docker compose ps. |
TLS handshake error from reverse proxy |
Certificate issue | Check Traefik logs. Ensure your domain points to your public IP and port 443 is open. |
Got permission denied while trying to connect to the Docker daemon socket |
User not in docker group | Run sudo usermod -aG docker $USER and log out/in. |
Conclusion
Setting up a homelab with Docker Compose is not just about getting services running—it's about building a system that survives updates, crashes, and your own mistakes. The best practices covered here—version pinning, secret management, health checks, and backups—are the foundation of a reliable setup. Start small, apply these patterns to one service, then scale to your entire stack.
Remember that security is a process, not a destination. Regularly update your images, audit your exposed ports, and test your backups. The time you invest in this discipline pays off when a service fails at 3 AM and you can restore it in minutes.
FAQ
Q: Should I use latest tag or pin a specific version?
A: Always pin a specific version in production. The latest tag can change unexpectedly and break your setup. Use a specific version like paperless-ngx:2.11.0 after checking the official GitHub releases. For development, latest is acceptable, but be prepared for breakage.
Q: How do I update my containers without downtime?
A: Use docker compose pull followed by docker compose up -d. This recreates containers with the new image. To avoid downtime, run at least two replicas of each service behind a load balancer or use rolling updates with docker compose up -d --scale app=2. For databases, use replication or accept a few seconds of downtime.
Q: Is it safe to expose the Docker socket to containers like Traefik?
A: It's a trade-off. Traefik needs the socket to discover containers dynamically. Mitigate the risk by using the :ro (read-only) flag and running Traefik with no-new-privileges:true. Never mount the socket into containers you don't trust completely.
Q: How often should I back up my data? A: For a homelab, daily backups are sufficient for most services. Use a schedule that matches your data's importance. For critical databases, consider hourly incremental backups. Always test your backups by restoring to a separate environment.
Q: Can I run Docker Compose on a Raspberry Pi?
A: Yes, but be mindful of ARM architecture. Not all images support ARM. Use docker manifest inspect <image> to check compatibility. Also, performance will be lower—expect 2-3x slower database queries compared to an x86 machine. Adjust your expectations and monitor resource usage.