Docker Compose is how most small production stacks on our VPS plans are run, and it is a fine way to do it. The regrets come from the defaults: containers that do not come back after a reboot, logs that fill the disk in month three, one runaway process that takes the whole box down, and a backup that captured the containers but not the data. Each of those has a two-line fix. Here they all are, in the order you will need them.
A layout that survives you
One directory per stack under /srv, with the Compose file, an .env for secrets and a data/ directory for bind mounts you want to see. Named volumes for everything else. Keep it boring:
/srv/app/
compose.yaml
.env # chmod 600, never in git
data/caddy/ # bind mounts you want to inspect
/var/lib/docker/volumes/ # named volumes, managed by Docker
The Docker one-click app installs Engine 27 with Compose v2 and puts the daemon's data on the NVMe root. If you add a separate volume later, move /var/lib/docker there with data-root in daemon.json rather than symlinking.
Come back after a reboot
Kernel updates reboot the server at night. Every service must declare what it wants to happen:
services:
app:
image: ghcr.io/example/app:1.8.2
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
db:
image: postgres:16.4
restart: unless-stopped
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
retries: 5
volumes:
pgdata:
Two habits in that snippet matter more than they look. Pin image tags to a version, never latest: a reboot that pulls a new major version of PostgreSQL at 4 a.m. is the outage you cannot debug. And health checks with depends_on conditions stop the app from crash-looping while the database is still starting.
Logs that do not fill the disk
By default Docker keeps every line every container ever wrote, in JSON, forever. A chatty app fills 40 GB in a few months. Fix it once for the whole daemon:
cat > /etc/docker/daemon.json <<'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "20m", "max-file": "5" },
"default-address-pools": [{ "base": "172.30.0.0/16", "size": 24 }]
}
EOF
systemctl restart docker
Existing containers keep their old settings until recreated; docker compose up -d --force-recreate applies it. The address-pool line is a bonus that stops Compose networks from colliding with a WireGuard or office range later.
One runaway process should not take the box
Without limits, a memory leak in one container invites the kernel's OOM killer to pick a victim, and it often picks the database. Set memory limits on everything, generous but finite:
deploy:
resources:
limits:
memory: 1g
cpus: "2.0"
Compose v2 honours deploy.resources.limits without Swarm. Size the limits so their sum is below the VPS's RAM minus about 1 GB for the host; on a VPS-4 with 8 GB that is roughly 6.5 GB to distribute. When a container hits its limit, only that container restarts, and the event log tells you which one.
A reverse proxy with automatic TLS
Bind application ports to localhost only and let one proxy own 80 and 443. Caddy in a container gets certificates on its own and reloads on config change:
services:
caddy:
image: caddy:2.8
restart: unless-stopped
ports: ["80:80", "443:443", "443:443/udp"]
volumes:
- ./data/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
app:
expose: ["3000"] # reachable by caddy on the compose network, not published
volumes:
caddy_data:
The Caddyfile is one line per site: app.example.com { reverse_proxy app:3000 }. Publishing only the proxy's ports also sidesteps the well-known problem of Docker punching holes through ufw: nothing but 80 and 443 is ever published.
Secrets
Put them in .env with chmod 600, reference them with ${DB_PASSWORD} in the Compose file, and keep .env out of git with a .env.example next to it. For anything beyond a handful of values, Compose file secrets: mounted as files are cleaner than environment variables, which leak into docker inspect and crash reports.
Updating without surprises
cd /srv/app
docker compose pull # fetches the new pinned tags you edited
docker compose up -d # recreates only what changed
docker image prune -f # frees the old layers
Edit the tag, pull, up. Watchtower-style auto-updaters are tempting on a single VPS and are also how a breaking change arrives at midnight; if you use one, pin to minor versions so it can only apply patch releases.
Back up volumes, not containers
Containers are disposable; volumes are the data. For a database, dump it rather than copying files under it:
docker compose exec -T db pg_dump -U postgres -Fc app > /srv/backups/app-$(date +%F).dump
For everything else, a tar of the volume from a throwaway container works: docker run --rm -v app_uploads:/v -v /srv/backups:/b alpine tar czf /b/uploads-$(date +%F).tgz -C /v .. Ship /srv/backups off the server with restic; the recipe is in Backups that actually restore. A panel snapshot before big updates covers the “undo” case in seconds.
Knowing when it breaks
docker compose ps shows health; docker stats --no-stream shows who is eating RAM. For a permanent view, the netdata add-on discovers containers automatically and graphs each one's CPU, memory and restarts. Alert on restarts: a container that restarted three times in an hour is telling you something before your users do.
The checklist
- Pinned tags,
restart: unless-stopped, health checks anddepends_onconditions. - Daemon-wide log rotation.
- Memory limits on every service, summing below the VPS's RAM.
- One reverse proxy publishing 80 and 443; everything else on the internal network.
- Secrets in a 600
.env, out of git. - Dumps and volume archives shipped off-site nightly, restored somewhere once a month.
A VPS-4 configured this way runs a typical stack of five to eight services for years without anyone logging in, which is the highest compliment a server can receive.
