All systems operational
Support
EN
Language

More languages are on the way.

Running Docker Compose in production on a VPS without regrets

Restart policies, log rotation, resource limits, a reverse proxy with automatic TLS and a two-line backup of your volumes.

CECheapServ EngineeringWritten by 8 min read
Stacked containers glowing on a floating platform
On this page10
  1. A layout that survives you
  2. Come back after a reboot
  3. Logs that do not fill the disk
  4. One runaway process should not take the box
  5. A reverse proxy with automatic TLS
  6. Secrets
  7. Updating without surprises
  8. Back up volumes, not containers
  9. Knowing when it breaks
  10. The checklist

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 and depends_on conditions.
  • 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.

CE
CheapServ Engineering

The people who build the provisioning pipeline, the panel and the storage layer.

Deploy your first server in under a minute.

Top up from $25 in BTC, ETH, XMR or USDT. Your balance never expires and unused funds are refundable.

Sign up now