Introduction
Docker Compose is perfect for small to medium infrastructures (up to ~10-20 containers on one VPS). But in production, you don't use a docker-compose like in dev. You must plan for:
- Healthchecks for automatic crash recovery
- Restart policies to restart after reboot
- Named volumes (no blind bind mounts)
- Isolated networks
- Secret management
- Limited logs
This guide gives you the production template you can reuse.
Prerequisites
- Linux VPS with Docker and Docker Compose v2 installed
- See the VeryCloud guide "Install Docker on Linux": https://verycloud.fr/docs/article/install-docker-linux
Step 1: Verify Docker
docker --version
docker compose version
Docker Compose v2 (docker compose, not docker-compose) required.
Step 2: Recommended project structure
For each app, organize:
/opt/myapp/
├── docker-compose.yml
├── .env # env variables (gitignore)
├── .env.example # public template
├── secrets/ # sensitive secrets (gitignore)
│ ├── db_password.txt
│ └── api_key.txt
├── data/ # bind mounts if needed
│ └── ...
└── config/ # custom configs
└── nginx.conf
Permissions:
sudo mkdir -p /opt/myapp/{secrets,data,config}
sudo chmod 700 /opt/myapp/secrets
Step 3: Production docker-compose.yml template
# /opt/myapp/docker-compose.yml
services:
app:
image: myapp:1.2.3 # ⚠️ pin exact version, not "latest"
container_name: myapp
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000" # Bind on localhost only, exposed via Nginx
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://app:${DB_PASSWORD}@db:5432/myapp
secrets:
- db_password
volumes:
- app_data:/data
- ./config/app.conf:/etc/app/app.conf:ro
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- frontend
- backend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
db:
image: postgres:16-alpine
container_name: myapp-db
restart: unless-stopped
environment:
- POSTGRES_DB=myapp
- POSTGRES_USER=app
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
redis:
image: redis:7-alpine
container_name: myapp-redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- backend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
app_data:
driver: local
db_data:
driver: local
redis_data:
driver: local
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # No Internet access for this network
secrets:
db_password:
file: ./secrets/db_password.txt
Key points:
restart: unless-stopped: auto-restart except if manually stopped- Pinned images: no
latest, always1.2.3 - Healthchecks on all critical services
- Named volumes: portable, doesn't pollute the system
- Separated networks: frontend (web visible) vs backend (DB isolated)
- Limited logs: 10 MB × 3 files max per container
- Resources: CPU/RAM caps so a runaway container doesn't eat everything
- Secrets: passwords in files, not in
environment
Step 4: .env file
sudo nano /opt/myapp/.env
# Database
DB_PASSWORD=will_be_overridden_by_secret_file
# Redis
REDIS_PASSWORD=ALongRedisPassword_42
# App
APP_PORT=3000
Reusable variables with ${VAR} in docker-compose.yml.
sudo chmod 600 /opt/myapp/.env
Step 5: File-based secrets
echo "ALongDBPassword_2024" | sudo tee /opt/myapp/secrets/db_password.txt
sudo chmod 600 /opt/myapp/secrets/db_password.txt
Container accesses it via /run/secrets/db_password and env via POSTGRES_PASSWORD_FILE=/run/secrets/db_password.
Advantage vs environment: POSTGRES_PASSWORD=...:
- Not visible in
docker inspect - Not exported in process logs/env
Step 6: Start the stack
cd /opt/myapp
sudo docker compose up -d
Verify:
sudo docker compose ps
sudo docker compose logs -f
Expected state:
NAME STATUS PORTS
myapp Up (healthy) 127.0.0.1:3000->3000/tcp
myapp-db Up (healthy)
myapp-redis Up (healthy)
Step 7: Healthchecks explained
healthcheck lets Docker know if a container is healthy. Format:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s # Check frequency
timeout: 5s # Max response time
retries: 3 # Attempts before marking "unhealthy"
start_period: 30s # Grace period at startup
depends_on: condition: service_healthy waits for the check to pass before starting the dependent app (e.g. app waits for DB to be ready).
Step 8: Automatic volume backup
Docker volumes are stored in /var/lib/docker/volumes/. To back them up:
sudo nano /usr/local/bin/backup-docker-volumes.sh
#!/bin/bash
set -e
BACKUP_DIR=/backup/docker
DATE=$(date +%F_%H%M)
mkdir -p $BACKUP_DIR
cd /opt/myapp
# Briefly stop DB for consistent snapshot (10s max)
docker compose stop db redis
# Backup volumes
for vol in $(docker volume ls -q --filter name=myapp_); do
docker run --rm \
-v ${vol}:/source:ro \
-v ${BACKUP_DIR}:/backup \
alpine tar -czf /backup/${vol}_${DATE}.tar.gz -C /source .
done
# Restart
docker compose start db redis
# Rotation: keep 7 days
find $BACKUP_DIR -type f -mtime +7 -delete
echo "Backup OK: $DATE"
sudo chmod +x /usr/local/bin/backup-docker-volumes.sh
sudo crontab -e
0 3 * * * /usr/local/bin/backup-docker-volumes.sh
For something cleaner, see the Restic + Backblaze B2 guide.
Step 9: Clean update
cd /opt/myapp
# Pull new image versions
sudo docker compose pull
# Recreate with new images
sudo docker compose up -d --remove-orphans
# Clean old images
sudo docker image prune -f
To automate, use Watchtower:
watchtower:
image: containrrr/watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_SCHEDULE=0 0 4 * * * # 4 AM
- WATCHTOWER_NOTIFICATIONS=email
⚠️ Watchtower updates to the latest tag. If you pin to 1.2.3, it won't update. For auto 1.x, use tag 1 or 1.2.
Step 10: Centralized logs
By default Docker logs are stored as JSON in /var/lib/docker/containers/. To centralize:
sudo docker compose logs --tail=100 -f
For multi-service aggregated, see the Loki + Promtail guide.
Step 11: Resource limits (very important)
Without limits, a leaking container can eat all RAM/CPU:
services:
app:
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
reservations:
memory: 512M
cpus: '0.5'
⚠️ In "compose" mode (not swarm), deploy: is partially supported. Prefer:
services:
app:
mem_limit: 1g
cpus: 1.0
Step 12: Front reverse proxy
Port 3000 is bound to 127.0.0.1 only. To expose in HTTPS, add Nginx or Traefik in front:
# /etc/nginx/sites-available/myapp
server {
listen 443 ssl http2;
server_name app.your-domain.com;
ssl_certificate /etc/letsencrypt/live/app.your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.your-domain.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Troubleshooting
Container restarts in loop
sudo docker compose logs app
Common causes:
- Missing env variable
- Volume with wrong permissions
- Healthcheck failing at startup (increase
start_period)
"no space left on device"
Clean up:
sudo docker system prune -af --volumes
⚠️ Removes everything unused. Be sure.
Huge logs filling disk
Check logging: is properly configured in each service. Otherwise, Docker logs can grow indefinitely.
Container can't see another
They must be on the same network. Check:
sudo docker compose config
sudo docker network inspect myapp_backend
Useful commands
# Stack state
sudo docker compose ps
# Logs
sudo docker compose logs -f service_name
# Restart a service
sudo docker compose restart app
# Rebuild and restart
sudo docker compose up -d --force-recreate
# Stop without removing
sudo docker compose stop
# Stop + remove containers (keeps volumes)
sudo docker compose down
# Stop + remove EVERYTHING (including volumes ⚠️)
sudo docker compose down -v
# Exec in a container
sudo docker compose exec app sh
# Inspect a service
sudo docker compose config
# View resources
sudo docker stats
# Networks
sudo docker network ls
# Volumes
sudo docker volume ls
sudo docker volume inspect myapp_db_data
Conclusion
With this template, your app is:
- Resilient: auto-restarts on crash
- Secure: isolated secrets, non-exposed backend
- Maintainable: named volumes, separated networks
- Observed: healthchecks, limited logs
Going further:
- Migrate to Docker Swarm or Kubernetes when you have 10+ services to orchestrate
- Use Portainer or Komodo to manage multiple stacks via UI
- Set up Watchtower for controlled auto-updates
Resources
- Docker Compose documentation: https://docs.docker.com/compose/
- VeryCloud guide — Install Docker: https://verycloud.fr/docs/article/install-docker-linux
- VeryCloud guide — Restic backup:
/docs/article/restic-b2 - VeryCloud guide — Centralized logs Loki:
/docs/article/loki-promtail


















