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.
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.
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.