Docker Compose isn’t just for development: on a single VPS (or a few), it’s a simple, solid way to run production apps. But a compose.yaml that works on a laptop needs a few additions before it runs reliably for months. Here’s a production checklist.
Basics: Docker Compose tutorial.
A production-ready example
# /opt/app/compose.yaml
services:
app:
image: registry.example.com/myapp:1.8.3 # pinned version, never :latest
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:8080:8080" # only reachable via the reverse proxy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
deploy:
resources:
limits:
cpus: "1.5"
memory: 768M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
db:
image: postgres:16.4
restart: unless-stopped
env_file: .env.db
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
interval: 10s
retries: 5
deploy:
resources:
limits:
memory: 1G
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
volumes:
pgdata:
The checklist
1. Pin image versions. postgres:16.4, or at least postgres:16, never latest, so a pull can’t silently jump a major version. See package pinning.
2. Restart policies. restart: unless-stopped brings containers back after crashes and reboots. Make sure Docker itself starts at boot: systemctl enable docker.
3. Healthchecks + depends_on. Containers wait for dependencies to be healthy, and docker ps shows unhealthy services.
4. Resource limits. Stop one container from starving the server. See Docker CPU and memory limits.
5. Don’t publish ports publicly. Bind to 127.0.0.1, and put a reverse proxy (Nginx, Caddy, Traefik) with HTTPS in front. Docker-published ports bypass UFW rules. See Docker and UFW and reverse proxy for multiple containers.
6. Secrets outside the compose file. Use .env files with chmod 600 (not in Git), or Docker/Compose secrets files. See Docker secrets and secrets management.
7. Limit logs. The default json-file driver grows forever. Set max-size/max-file per service, or globally in /etc/docker/daemon.json. See Docker logging drivers.
8. Named volumes or bind mounts for data, and back them up. See Docker volume backups.
9. Run as non-root where possible (user: "1000:1000"), with read-only filesystems for stateless apps (read_only: true + tmpfs for /tmp), and cap_drop: [ALL]. See Linux capabilities.
10. Keep the project in Git (compose file, configs, without secrets), so the deployment is reproducible.
Deploying updates
cd /opt/app
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
up -drecreates only containers whose image or config changed.- Expect a few seconds of downtime per recreated container. For zero-downtime, run two app replicas behind the proxy and update one at a time, or use a tool such as docker-rollout, or Swarm/Kubernetes. See Docker Swarm.
- Automate with CI (GitHub Actions over SSH), rather than editing on the server. See GitHub Actions deploy to VPS.
Automatic updaters (Watchtower) are convenient for hobby setups. For production, deliberate, tested updates are safer. See Watchtower.
Monitoring
docker compose ps,docker stats,docker compose logs -f --tail=100 app.- External uptime checks against the public URL. See Uptime Kuma.
- Disk usage of
/var/lib/docker. See Docker disk cleanup.
Start on boot
With restart: unless-stopped, containers start automatically when Docker starts. No extra systemd unit is needed, unless you want docker compose up to run at boot for other reasons.
Quick reference
| Item | Setting |
|---|---|
| Versions | Pinned tags |
| Recovery | restart: unless-stopped + healthchecks |
| Limits | deploy.resources.limits |
| Ports | 127.0.0.1:host:container + reverse proxy |
| Logs | max-size, max-file |
| Update | pull → up -d → image prune |
Related: Docker VPS hosting and Coolify.