← All articles

Running a Production SaaS on One VPS: Health-Checked Deploys and Backups You Actually Restore

By Shuvo Haldar5 min read
DevOpsDockerSaaSBackupsDeployment
Running a Production SaaS on One VPS: Health-Checked Deploys and Backups You Actually Restore

There's a belief that a "real" production SaaS needs a cluster, a managed database, and a platform team. FeedbackRun — a live, multi-tenant, paid product I built at Nextbit — runs on a single VPS with Docker Compose. Marketing site, GraphQL API, background workers, Postgres, Redis, all of it on one box. That's not a compromise I'm apologizing for; for a product at this stage it's the right call. But running on one box only works if two things are genuinely solid: deploys that can't leave you broken, and backups you've actually restored.

Animated deploy flow: the old version keeps serving while a new version starts and is probed by /health?deep=1, which checks Postgres, Redis, and migrations; if it goes green in time traffic cuts over, otherwise the deploy rolls back to the old container that was never removed.
The new version has to pass a deep health check before it earns the traffic — if it doesn't go green, the deploy rolls back automatically.

A deploy has to prove itself before it takes over

The failure mode that kills single-box setups is a bad deploy that takes the whole service down with it. So my deploys don't just docker compose up and hope. A new version has to pass a deep health check before it's allowed to serve traffic, and if it doesn't go green in time, the deploy rolls back automatically to the version that was already working.

The health check is the key, and it has to be deep. A shallow /health that returns 200 as soon as the process starts tells you nothing — the process is up but Postgres might be unreachable, a migration might have half-applied, Redis might be down.

// /health?deep=1 — actually touch every dependency, don't just say "OK".
app.get("/health", async (req, reply) => {
  if (req.query.deep !== "1") return reply.send({ status: "ok" }); // shallow: liveness

  const checks = await Promise.allSettled([
    db.$queryRaw`SELECT 1`,        // Postgres reachable + accepting queries
    redis.ping(),                  // Redis reachable
    checkMigrationsApplied(),      // schema is where the code expects it
  ]);
  const failed = checks.filter((c) => c.status === "rejected");
  if (failed.length) return reply.code(503).send({ status: "degraded", failed: failed.length });
  return reply.send({ status: "ok" });
});

The deploy script waits on /health?deep=1, not /health. That one query parameter is the line between "the container started" and "the app can actually serve a request," and only the second one earns the traffic cutover. If it never turns green, the old container is still there and the script brings it back.

A backup you haven't restored is a rumor

Here's the part most teams skip and later regret. Nightly encrypted backups go to S3-compatible storage — fine, everyone does that. But a backup file existing is not the same as a backup working. Corruption, an incomplete dump, a restore procedure nobody has run since the schema changed — you find all of these at the worst possible moment, during a real outage, unless you find them on purpose first.

Diagram of the backup restore-test: a nightly encrypted dump goes to S3-compatible storage, and once a week a job restores the latest backup into a scratch database, runs a row-count sanity check, then drops it — succeeding intact or failing loudly thanks to set -euo pipefail.
The weekly job proves the backup restores into a scratch DB and fails loudly if it doesn't — so you learn on a calm Tuesday, not during an incident.

So once a week, an automated job actually replays the latest backup into a throwaway database and checks it comes back intact. Not "does the file exist" — "does restoring it produce a working database." If the restore fails, I hear about it on a calm Tuesday, not during an incident.

# Weekly: prove the backup restores, on a schedule, into a scratch DB.
set -euo pipefail
LATEST=$(aws s3 ls "s3://$BUCKET/backups/" | sort | tail -1 | awk '{print $4}')
aws s3 cp "s3://$BUCKET/backups/$LATEST" /tmp/backup.sql.enc
openssl enc -d -aes-256-cbc -pbkdf2 -in /tmp/backup.sql.enc -out /tmp/backup.sql -pass env:BACKUP_KEY
createdb restore_test
psql restore_test < /tmp/backup.sql          # if this fails, the job fails loudly
psql restore_test -c "SELECT count(*) FROM workspaces;"  # sanity-check real data landed
dropdb restore_test

set -euo pipefail matters more than it looks: it makes the script fail loudly the instant any step breaks, instead of limping to the end and reporting success. A restore-test that can silently pass is worse than no test, because it sells you confidence you didn't earn.

What you keep and what you drop on one box

Running on a single VPS doesn't mean running without discipline. I keep full observability — Prometheus metrics on the API and workers, distributed tracing, error tracking, structured logs — because when there's one box, you need to see inside it clearly. What I don't keep is the operational overhead of a cluster I don't need yet.

My honest position: scale the infrastructure when the product's traffic demands it, not because a diagram looks more impressive with more boxes. Premature clustering is a tax you pay in complexity every single day for a scale you may never hit. A well-instrumented single VPS with auto-rollback deploys and proven backups will carry a real paid SaaS a long way — and the day it can't, the observability tells you exactly why, and the clean Docker setup makes moving out straightforward.

Takeaways

  • One VPS with Docker Compose is a legitimate home for a real paid SaaS — if the fundamentals are solid.
  • Gate deploys on a deep health check that touches Postgres, Redis, and migrations, and roll back automatically when it doesn't go green.
  • A backup is a rumor until you've restored it. Replay it into a scratch DB on a schedule and fail loudly.
  • Keep full observability; drop the cluster overhead until traffic actually demands it.

Reliability isn't the number of boxes. It's whether a bad deploy can hurt you and whether your backup is real — and both of those you can solve on one machine.


I build and run production SaaS platforms end-to-end. If you're working on something similar, let's talk.

Building something similar?

I'm a senior full-stack & DevOps engineer available for full-time roles and projects worldwide.