VPS Tutorials
How to Configure Docker Compose for Production on a VPS (2026): Healthchecks, Resource Limits, Logs, Secrets, and a Rollback Checklist
Just because Docker Compose can get your services running doesn't mean they're ready for production. The most common problems on a VPS are containers without healthchecks, misuse of restart policies, no CPU/memory limits, logs filling up the disk, secrets leaking through .env, no Volume backup before updates, and no rollback plan. This article explains how to harden Docker Compose for production, following the real order of going live.

Docker Compose is a great fit for a VPS: one machine, a few services, one compose.yml, and it's much cleaner than manually installing Nginx, a database, and Redis.
But many people's Compose files only achieve "it starts":
services:
app:
image: myapp:latest
restart: always
ports:
- "3000:3000"
That's fine in a test environment, but it's dangerous in production. A container dies and nobody knows, logs fill up the system disk, the database has no healthcheck, updates pull latest directly, .env is stuffed with secrets, and when something goes wrong nobody knows how to roll back.
This article doesn't cover how to install Docker. For basic installation, see:
This article solves just one problem: your Docker Compose project already runs—how do you change it into a configuration better suited to a VPS production environment?
A reasonably reliable VPS Docker Compose production configuration should at least consider:
- Pinning image versions, not blindly using
latest; - Every critical service has a
healthcheck; - A sensible
restartpolicy; - CPU, memory, process count, or ulimit limits;
- Log rotation to prevent the disk from filling up;
- Secrets not written directly into the compose file;
- Backups for data volumes, databases, and upload directories;
- A rollback plan before updating.
If you only want to remember one sentence: Compose isn't the problem—the problem is copying a development Compose file unchanged into production.
Many tutorials like to write:
image: nginx:latest
image: postgres:latest
image: redis:latest
That's convenient for local testing, but on a VPS production environment it easily causes incidents. You docker compose pull today, tomorrow the upstream image updates, the major version changes, configuration behavior changes, database compatibility changes, and the site may simply fail to start.
A more stable approach is to pin versions:
services:
web:
image: nginx:1.26-alpine
db:
image: postgres:16-alpine
redis:
image: redis:7.2-alpine
For application images, also use explicit tags:
image: ghcr.io/example/myapp:2026-06-14-1
Don't turn production into "a blind box every time you update."
If you must use latest, at least do this:
- Back up data volumes before updating;
- Bring it up on a test machine first;
- Confirm the migration scripts;
- Keep the previous image version;
- Have explicit rollback commands.
depends_on in Compose only solves "startup order"; it can't prove the service is actually available.
For example, the database container process starts but hasn't accepted connections yet; the application container starts but the database migration fails; Nginx starts but the reverse proxy backend is unreachable. Without healthchecks, you only see the container status as running, but users are already hitting a broken site.
A web application can be written like this:
services:
app:
image: ghcr.io/example/myapp:2026-06-14-1
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
PostgreSQL:
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
MySQL / MariaDB:
services:
db:
image: mariadb:11
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u root -p$${MARIADB_ROOT_PASSWORD} --silent"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
Redis:
services:
redis:
image: redis:7.2-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
Then make the application wait for the database to be healthy:
services:
app:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
Note: condition: service_healthy depends on Compose V2. Older tutorials may not apply.
Many templates uniformly write:
restart: always
It's not wrong, but it doesn't suit every service.
Common choices:
| Policy | Suitable scenarios | Risks |
|---|---|---|
no | One-off tasks, migration scripts | Won't auto-recover if it crashes |
on-failure | Task-type services, workers | Won't restart on non-abnormal exit |
unless-stopped | Most web services | Won't auto-start with Docker restart after being manually stopped |
always | Base services that must stay resident | May restart even after being manually stopped, easy to misunderstand |
For small projects on a VPS, the usual recommendation is:
restart: unless-stopped
For migration tasks and initialization tasks, don't write auto-restart. Otherwise a failed migration may run repeatedly, making things messier the more you fix.
If your problem is that services don't recover after a VPS reboot, see this article:
The biggest fear on a VPS is one container running out of control, eating all the machine's memory, and finally the OOM Killer randomly killing processes.
Compose can limit resources. Support for deploy.resources has historically differed across Docker/Compose scenarios; Compose V2 is now more common, but you still need to verify in your own environment.
Example:
services:
app:
image: ghcr.io/example/myapp:2026-06-14-1
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
reservations:
memory: 256M
If it doesn't take effect in your environment, you can use the compatible form:
services:
app:
mem_limit: 768m
mem_reservation: 256m
cpus: 1.0
You can also limit the number of processes:
services:
app:
pids_limit: 256
And the number of open files:
services:
nginx:
ulimits:
nofile:
soft: 65535
hard: 65535
How to set them reasonably? First look at the service type:
| Service | Suggested limiting approach |
|---|---|
| Nginx/Caddy | CPU doesn't need to be high; nofile can be relaxed |
| Node/Python apps | Focus on limiting memory to prevent leaks from taking down the machine |
| MySQL/PostgreSQL | Not recommended to be too small; databases need stable memory |
| Redis | Must set Redis's own maxmemory policy |
| Worker/queues | Limit CPU/memory to avoid batch jobs saturating the VPS |
If your VPS is already OOMing, see:
Docker's default json-file logs, if unlimited, will fill the disk over long-term operation.
This kind of failure is very common:
- Application error loops;
- Nginx access log spikes;
- Crawlers or CC attacks hammering APIs;
- Containers printing SQL or debug logs to stdout;
- A month later the system disk suddenly hits 100%.
Add log limits to services:
services:
app:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
It's recommended for Nginx, applications, and workers. Be more careful with database logs to avoid key diagnostic information being overwritten too early, but they also must not grow without limit.
Check Docker log usage:
docker system df
sudo du -h /var/lib/docker/containers | sort -h | tail -20
If Docker has already filled the disk, see:
Don't do this:
environment:
DATABASE_PASSWORD: my-secret-password
API_KEY: sk_live_xxx
compose.yml easily ends up in Git, and even gets copied into tickets, group chats, and documents.
A better approach is:
services:
app:
env_file:
- .env.production
Don't commit .env.production to Git:
.env
.env.*
!.env.example
Provide an .env.example:
DATABASE_URL=postgres://user:password@db:5432/app
REDIS_URL=redis://redis:6379/0
APP_SECRET=change-me
If you're using Docker Swarm or a more complex environment, consider Docker secrets. But for a single VPS, managing .env well, setting permissions, and encrypting backups already avoids most incidents.
Set permissions:
chmod 600 .env.production
Don't write secrets into the image during the build stage. Distinguish build arguments from runtime environment variables.
Many Compose files like to do this:
ports:
- "5432:5432"
- "6379:6379"
- "3000:3000"
This exposes the database, Redis, and application ports directly to the public internet. Production usually doesn't need this.
A safer approach is:
- Only expose Nginx/Caddy on 80/443;
- Expose application services only on the Docker internal network;
- Don't map databases and Redis to host public ports;
- Put admin panels behind a VPN, SSH tunnel, or allowlist.
Example:
services:
proxy:
image: caddy:2
ports:
- "80:80"
- "443:443"
networks:
- frontend
app:
image: ghcr.io/example/myapp:2026-06-14-1
expose:
- "3000"
networks:
- frontend
- backend
db:
image: postgres:16-alpine
networks:
- backend
networks:
frontend:
backend:
internal: true
internal: true can keep the backend network from directly reaching the external network, which suits database-layer isolation. But note: if some services need external network access to update data, don't put them in a fully internal network.
Don't open databases to the public internet; this has been covered here:
Containers can be deleted, images can be re-pulled, but losing a Volume is a real incident.
First list where the data is:
docker volume ls
docker volume inspect volume_name
In Compose, it's recommended to name them explicitly:
volumes:
postgres_data:
redis_data:
uploads:
Mount them in the service:
services:
db:
volumes:
- postgres_data:/var/lib/postgresql/data
app:
volumes:
- uploads:/app/uploads
Before updating, do at least three things:
- Database dump;
- Volume backup;
- Save the current
compose.ymland.env.
If you want to do a recovery drill, see:
Don't assume VPS snapshots are enough. Snapshots suit short-term rollback; long-term backups still rely on an independent backup repository.
In production, don't just do:
docker compose pull
docker compose up -d
A more stable process:
# 1. 记录当前版本
docker compose ps
docker images | grep myapp
# 2. 备份 compose 和 env
cp compose.yml compose.yml.bak.$(date +%F-%H%M)
cp .env.production .env.production.bak.$(date +%F-%H%M)
# 3. 备份数据库和关键 volume
# 这里按你的数据库类型执行 dump
# 4. 拉取新镜像
docker compose pull
# 5. 启动
docker compose up -d
# 6. 看健康状态
docker compose ps
docker compose logs --tail=100 app
If it fails, roll back:
# 改回旧镜像 tag
vim compose.yml
docker compose up -d
So image tags must be pinned. If you use latest, when rolling back you won't even know what the previous version was.
Below is a simplified template. It's not a universal configuration, but it includes several categories of things production must consider:
services:
proxy:
image: caddy:2.8
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
networks:
- frontend
app:
image: ghcr.io/example/myapp:2026-06-14-1
restart: unless-stopped
env_file:
- .env.production
expose:
- "3000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
mem_limit: 768m
cpus: 1.0
pids_limit: 256
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
networks:
- frontend
- backend
db:
image: postgres:16-alpine
restart: unless-stopped
env_file:
- .env.production
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
logging:
driver: json-file
options:
max-size: "100m"
max-file: "5"
networks:
- backend
redis:
image: redis:7.2-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- backend
networks:
frontend:
backend:
internal: true
volumes:
caddy_data:
caddy_config:
postgres_data:
redis_data:
Pre-launch check:
docker compose config
docker compose up -d
docker compose ps
docker compose logs --tail=100
docker compose config is very useful: it expands the final merged configuration, helping you spot syntax and variable issues in advance.
Before putting a Compose project into a VPS production environment, at least confirm:
- Image tags are pinned and don't depend on
latest; - Web, database, and Redis all have healthchecks;
- Critical services use
restart: unless-stopped; - Application containers have CPU/memory/PID limits;
- Docker logs have max-size/max-file set;
-
.env.productionis not in Git and has permissions 600; - Database and Redis don't expose public ports;
- Volume names are explicit and the backup strategy is clear;
- Before updating, you back up the database, Volumes, compose file, and env;
- You can roll back using the old image tag;
- Nginx/Caddy reverse proxy and HTTPS work properly;
-
docker compose psshows no unhealthy services.
It suits small and medium projects, personal SaaS, tool sites, blogs, internal systems, and low-complexity multi-service applications. It's not suited for complex multi-node scheduling, but running a few services on one VPS makes Compose simpler and more controllable than Kubernetes.
For most VPS production services, unless-stopped is more intuitive. After you manually stop the service, it won't automatically come back up when Docker restarts. For services that must stay resident and shouldn't be affected by manual stops, consider always.
Not necessarily. healthcheck marks health status; whether to restart also depends on service behavior and external orchestration. At minimum it lets you use docker compose ps, monitoring systems, and deployment scripts to judge whether the service is actually available.
Limits that are too small affect performance, but no limits at all is more dangerous. On a VPS, set conservative upper bounds first to prevent a single container from taking down the whole machine, then adjust gradually based on monitoring.
No. .env only avoids writing secrets into the compose file. You also need to ensure it doesn't enter Git, permissions are strict enough, backups are encrypted, and secrets aren't printed in logs.
Running Docker Compose in production on a VPS is fine, provided you don't treat a "it starts" configuration as a "it runs stably long-term" configuration.
What really needs to be filled in: pinned versions, healthchecks, resource limits, log rotation, secret management, port isolation, Volume backups, and rollback-capable updates. Once these are done, a Compose project on an ordinary VPS goes from a "toy deployment" to a "maintainable production deployment."