Rich Gibbs

Docker Compose on one VPS: the production checklist before you outgrow it

docker · docker-compose · vps · devops · sysadmin · indie-founder · operations · self-hosting

Direct answer

Docker Compose can be production-ready on one VPS when restart policy, health checks, log rotation, backups, explicit ports, secret handling, rollback, and alerts are all written down and tested.

Docker Compose is fine for a one-server app.

That sentence makes some people twitch, so here is the boundary: one VPS, one operator, a small app, a reverse proxy, a database you understand, and a business where “five minutes down while I fix it” is annoying but not catastrophic.

That setup does not need Kubernetes on day one. It does need a boring checklist, because most Compose outages are not deep container problems. They are simpler:

  • the process did not come back after reboot
  • logs filled the disk
  • a private port was accidentally published to the internet
  • the .env file became the only copy of production secrets
  • the database volume was “backed up” but never restored
  • the deploy command was whatever you typed last time from shell history
  • the health check said “container running” while the app was dead

Compose is not the problem in that story. Undocumented production habits are.

This is the checklist I would want in place before trusting a Docker Compose app on one VPS.

1. Write down the actual shape of the system

Before changing YAML, write one short inventory:

  • public hostnames
  • Compose project directory
  • services in compose.yml
  • published ports
  • named volumes
  • bind mounts
  • environment files
  • backup target
  • deploy command
  • rollback command
  • who gets paged when it breaks, even if “who” is just you

The act of writing that list will catch half the mistakes.

If you cannot say which volume contains production data, you do not have a deployment. You have a container that happens to be running.

2. Make the reverse proxy the only public door

For a one-box Compose app, I want exactly one public entry path:

  • 80/tcp and 443/tcp to Caddy, nginx, Traefik, or Apache
  • app containers reachable only on the Docker network or loopback
  • database and cache ports not published publicly
  • admin tools disabled or bound to 127.0.0.1

The dangerous Compose line is usually this:

ports:
  - "5432:5432"

That publishes Postgres on every interface unless the host firewall saves you.

Prefer one of these shapes:

ports:
  - "127.0.0.1:3000:3000"

or no host port at all, with the reverse proxy joining the same Compose network.

Then verify from outside the box:

nmap -Pn -p 1-10000 your.server.ip

You should see SSH, HTTP, HTTPS, and very little else. If Redis, Postgres, MySQL, Meilisearch, Elasticsearch, or an admin dashboard shows up, stop and fix that before touching the application.

3. Use a restart policy, but do not confuse it with health

Docker documents restart policies for the basic “come back after exit or daemon restart” behavior. For a one-VPS app, unless-stopped is usually the least surprising default.

services:
  web:
    image: registry.example.com/myapp:2026-05-24
    restart: unless-stopped

That gets you through process exits and host reboots.

It does not prove the app is healthy.

A wedged app can keep a process alive forever. A web server can return 500s while the container is “Up”. A worker can be connected to the wrong queue and look fine from Docker’s point of view.

So pair restart policy with a real health check.

4. Add health checks that test the thing users need

Docker Compose supports service health checks in the Compose file. The command can be a list form or shell form; the important part is that it tests behavior, not just process existence.

services:
  web:
    image: registry.example.com/myapp:2026-05-24
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/healthz >/dev/null || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s

A good health check answers one narrow question: “Can this service do the small thing users depend on?”

For a web app, that might be /healthz returning 200 after checking the database connection.

For a worker, it might be a queue heartbeat or a lightweight dependency check.

Do not make health checks expensive. Do not run migrations inside them. Do not call third-party APIs every 30 seconds. If the health check creates its own outage, it failed the assignment.

5. Put log rotation in the Compose file

The most boring production outage is a full disk.

Docker’s default json-file logging driver writes container output to JSON files on the host. Docker’s docs call out options such as max-size and max-file for limiting those logs. Use them.

services:
  web:
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "5"

Then check the host:

docker system df
du -h /var/lib/docker/containers 2>/dev/null | sort -h | tail
df -h /

If logs can fill the root volume, your uptime depends on how chatty the app gets during an incident. That is not a plan.

6. Treat .env as config, not a secret vault

Compose reads environment files because it is convenient. Convenience is not the same as secret management.

For a one-person VPS, I am not going to pretend every app needs Vault, SOPS, KMS, and a ceremony. But the minimum line is still clear:

  • .env is not committed
  • .env is mode 0600 or at least not world-readable
  • secrets are not printed in deploy logs
  • backups do not spray .env into random buckets
  • the production .env file has a second recoverable copy somewhere intentional
  • old API keys get rotated when contractors, incidents, or accidental exposure make that necessary

Docker Compose also has secrets and configs concepts in the Compose specification. On a single VPS, even a simple file-mounted secret can be better than passing everything as environment variables, because it gives you a clearer boundary for “this file is sensitive.”

The main rule is not fancy: know where secrets live, know who can read them, and know how to rotate them.

7. Name volumes like you will have to restore them at 2am

This is the difference between a recoverable Compose app and a science project.

Bad:

volumes:
  data:

Better:

volumes:
  postgres_data:
  uploads_data:

Best: a README next to the Compose file that says:

postgres_data  -> production database
uploads_data   -> user uploads
backup target  -> s3://example-backups/myapp/
restore drill  -> docs/restore.md
last tested    -> 2026-05-24

If your database is inside Compose, backup and restore are production features. Not ops chores. Not future hardening. Product features.

At minimum:

docker compose exec -T db pg_dump -U app app > backup.sql

and a documented restore command that you have run on a clean database.

A backup you have never restored is just a comforting file.

8. Keep the deploy command boring and repeatable

The deploy path should be a script or runbook, not shell history.

For a pull-based deploy:

set -euo pipefail

cd /opt/myapp
docker compose config >/dev/null
docker compose pull
docker compose up -d --remove-orphans
docker compose ps
docker compose logs --since=10m --tail=200 web

For a build-on-host deploy:

set -euo pipefail

cd /opt/myapp
docker compose config >/dev/null
docker compose build --pull
docker compose up -d --remove-orphans
docker compose ps

That is not sophisticated. That is the point.

You want the same commands every time so that when a deploy fails, you are debugging the deploy, not your memory of the deploy.

9. Have a rollback that does not require creativity

If images are tagged only as latest, rollback is guesswork.

Prefer immutable-ish tags:

services:
  web:
    image: registry.example.com/myapp:2026-05-24-1842

Then rollback is boring:

cd /opt/myapp
git checkout previous-known-good-compose-file
docker compose pull
docker compose up -d --remove-orphans

If you build on the host, keep the previous image around long enough to go back.

If the database migration is not backwards-compatible, write that down before deploying. The worst rollback plan is “we can roll the app back, but not the data.”

10. Start Compose from systemd, not a forgotten terminal

If the host reboots, the app should return without you SSHing in.

One plain systemd unit can be enough:

[Unit]
Description=My app Docker Compose stack
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0

[Install]
WantedBy=multi-user.target

Then:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp-compose.service
systemctl status myapp-compose.service

This does not replace container restart policies. It gives the host one obvious owner for the stack lifecycle.

11. Alert on the boring host signals

For a one-VPS Compose app, the first useful alerts are not advanced:

  • root disk over 80 percent
  • memory pressure or swap thrash
  • public HTTP check failing
  • TLS certificate near expiry
  • Docker daemon down
  • app health endpoint failing
  • backup job missing its last-success marker
  • root-owned files accidentally created in bind mounts

That list catches more real incidents than a beautiful dashboard that nobody reads.

Start with cron, systemd timers, Uptime Kuma, Healthchecks.io, Better Stack, or whatever you will actually maintain. The tool matters less than the habit: an external check must notice when the box is not serving users.

12. Know when Compose is no longer the right tool

Compose on one VPS stops being cute when:

  • downtime during one-host maintenance is unacceptable
  • the database needs managed backups, replication, or point-in-time recovery
  • one deploy must roll across multiple hosts
  • the team needs per-service ownership and access control
  • traffic bursts require horizontal scaling
  • compliance or customer contracts require stronger operational evidence
  • the restore path depends on one person remembering everything

That does not mean “move to Kubernetes.” It means the shape changed.

Maybe the next step is managed Postgres and the app still runs in Compose. Maybe it is a second VPS. Maybe it is Fly.io, Render, ECS, Nomad, Kubernetes, or something boring from your cloud provider.

The important part is not defending Compose forever. It is knowing what risk you accepted while Compose was the right level of machinery.

The short version

Before you trust Docker Compose on one VPS, make these true:

  • one public door: reverse proxy only
  • no accidental public database/cache/admin ports
  • restart: unless-stopped or another deliberate restart policy
  • health checks that test real behavior
  • Docker log rotation
  • .env protected and recoverable
  • named volumes with a restore-tested backup path
  • one repeatable deploy command
  • one boring rollback path
  • systemd owns the stack on boot
  • external checks catch HTTP, disk, cert, Docker, and backup failures
  • a written threshold for when this setup has been outgrown

Compose is a good tool for a one-server business when the operator is honest about its edges.

The danger is not that Compose is too small.

The danger is pretending a running container is the same thing as a production system.

Sources

Frequently asked questions

Is Docker Compose enough for production on one VPS?

Yes, for a small product, if the host has health checks, restart policies, backups, log rotation, rollback, monitoring, and clear ownership of ports and secrets.

What is the first Docker Compose production check?

Confirm every important service has a restart policy, a health check or external probe, persistent volumes where needed, and a documented restore path.

When should a founder outgrow Docker Compose?

Outgrow it when one host is no longer an acceptable failure domain, deploys need multi-node scheduling, or operational complexity exceeds what a single VPS can safely absorb.