10 Mistakes That Break a Self-Hosted SaaS
Cloud

10 Mistakes That Break a Self-Hosted SaaS

None of these ten show up as a single dramatic outage. They show up as a disk that quietly fills, a rollback that turns out to be impossible, a backup nobody ever restored. Here's the mechanism behind each one, and the fix.

Published September 27, 202611 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

What are the most common, recurring mistakes that break a self-hosted SaaS, and how do you actually fix each one?

Ten failure modes recur across almost every self-hosted stack, independent of who's running it: exposing a database port through Docker's own iptables rules, treating a local backup as sufficient, never actually restoring from backup, letting container logs fill the disk, deploying on a moving `latest` tag, baking secrets into an image layer, running with no swap, auto-running migrations on deploy, letting a cert renewal fail without anyone noticing, and building on the same box that's serving production traffic. Each one has a specific mechanism and a specific, unglamorous fix.

  • These aren't rare edge cases — they're the default behavior of Docker, Linux, and most deploy tooling unless someone deliberately configures around them
  • Roughly half of these are data-loss risks and half are just downtime, and the post is explicit about which is which
  • The database-port mistake is covered in full mechanism elsewhere on this site; here it's one line item among ten, not the whole post
  • The pattern underneath all ten is the same: whatever the platform does by default is optimized for a demo, not for a server nobody's watching at 3am
  • Fixes are all cheap before the mistake bites and expensive after — the post ranks them that way, not by how scary they sound

Evidence notes

Docker json-file logging driver

Docker's default json-file log driver applies no size limit and performs no log rotation unless max-size and max-file are explicitly set in daemon.json or per-container log options. Confirmed against Docker's own docs, September 2026.

Let's Encrypt certificate lifetime

Standard Let's Encrypt certificates are valid 90 days, with renewal recommended around the 60-day mark. Let's Encrypt has announced a move to 45-day certificates by 2028, which shortens the window a silent renewal failure has before it's user-facing.

Docker port publishing bypasses ufw

Docker writes DNAT rules into the nat table's PREROUTING chain ahead of ufw's INPUT chain, so a published container port is reachable regardless of firewall rules. Full mechanism and fix covered in the fresh-VPS security post.

Continue with purpose

Most "mistakes I made" posts about self-hosting are really just war stories, and war stories are the wrong unit of information here — a specific person's specific bad night doesn't tell you whether the same thing will happen to you. What's actually useful is the mechanism: which of these failures are just how the software behaves by default, unless someone deliberately configures around it. Ten of them come up constantly, across completely different stacks and completely different operators, because they aren't personality flaws. They're defaults.

None of these are exotic. None require a sophisticated attacker or a freak coincidence. Docker ships with no log rotation until you turn it on. `latest` is a pointer, not a version, unless you stop using it. A backup you've never restored is a hypothesis wearing a backup's clothes. Every item below follows the same shape: here's what actually happens, here's why the default produces that outcome, here's the fix. Turning 10 mistakes that break a self-hosted SaaS into something a commercial team can run is the problem the XenGrowth practice works on.

What actually breaks, in the order it tends to bite?

  1. Publishing a database port through Docker so ufw never sees it. Run `docker run -p 5432:5432 postgres` on a box with ufw configured to deny everything but 22/80/443, and Postgres is reachable from the entire internet anyway. Docker writes its port-publishing rule as a DNAT entry in iptables' nat table PREROUTING chain, then forwards the rewritten packet through the FORWARD chain — both of which are evaluated before ufw's INPUT chain even sees the connection. Your firewall isn't misconfigured; it's just never consulted for that traffic. The fix is to bind published ports to localhost (`-p 127.0.0.1:5432:5432`) and let a reverse proxy be the only thing with a real public listener, or add explicit rules to the DOCKER-USER chain, which Docker does check. This one has a full write-up of its own — worth reading in full rather than re-deriving from a bullet point.

  2. Treating a local backup as a backup. A nightly `pg_dump` written to the same disk the database lives on protects against exactly one failure mode: accidentally deleting a table. It does nothing for a dead disk, a compromised hosting account, or a provider having a genuinely bad day in the region your one box happens to live in — all of which take the app and its only copy of the data out simultaneously. A backup that lives in the same blast radius as the thing it's backing up isn't a backup, it's a second copy of the same risk. The fix is the boring 3-2-1 shape: at least one copy on different infrastructure entirely, ideally a different provider and account, written there automatically rather than as a manual step someone has to remember.

  3. Never actually restoring from that backup. A backup job can report success for months while producing a file that doesn't actually restore — a version mismatch between `pg_dump` and the server it's meant to load into, a credential that quietly rotated, a cron job that's been silently failing since a config change three deploys ago. "The backup ran" and "the backup works" are different claims, and the second one is the only one that matters. The fix is scheduling an actual restore, periodically, into a disposable database, and comparing row counts or checksums against production. If nobody has watched this succeed, it's not verified — it's assumed.

  4. Letting container logs fill the disk. Docker's default json-file logging driver applies no size cap and performs no rotation unless `max-size` and `max-file` are explicitly configured in `daemon.json` or per-container. A chatty container — a debug log left on, a retry loop logging every failed attempt — can accumulate gigabytes over a few weeks with nothing warning you, until the disk fills and Postgres refuses writes, at which point the app looks broken for reasons that have nothing to do with the app. The fix is two lines in `daemon.json` setting a max size and file count per container, applied before it's needed rather than diagnosed after a full disk takes down an unrelated service.

  5. Deploying on `latest` and discovering rollback doesn't actually work. `latest` isn't a version number, it's a mutable pointer that gets reassigned every time a new image is pushed without an explicit tag. The moment that happens, there's no way to redeploy "whatever was running an hour ago," because the tag that used to point at it has already moved on to the new image. Rollback isn't slow in this setup — it's undefined. The fix is pinning deploys to an immutable reference: a git SHA, a semantic version, or an image digest, and keeping a small window of previous tagged images around specifically so rollback is a real, tested action rather than a hope.

  6. Baking secrets into the image instead of injecting them at runtime. An `ENV` line or a `COPY .env` in a Dockerfile lands the secret in a specific image layer permanently. Deleting the file in a later layer doesn't remove it from the image — the earlier layer with the secret in plaintext is still part of the image's history, and anyone who can pull the image (from a registry, from a leaked build cache, from a misconfigured public repo) can extract it with a basic layer inspection. The fix is injecting secrets at runtime only: environment variables supplied by the deploy platform, mounted files, or a secrets manager — never anything that ends up baked into a layer during `docker build`.

  7. Running a small box with no swap configured. Without swap, the moment RAM fills, Linux's OOM killer picks a process to terminate outright, and it doesn't necessarily pick the process that caused the spike — it can just as easily kill Postgres or the app server instead. There's no graceful slowdown, just a sudden kill with no warning to the app itself. Swap doesn't solve a memory problem, but it buys the box time to recover instead of getting summarily executed the instant memory pressure spikes. The fix is a 1–2 GB swap file on any small VPS, understood as a pressure valve rather than a substitute for sizing RAM correctly in the first place.

  8. Letting migrations run automatically on every deploy. An auto-run migration executes the moment the new container starts — before anyone has confirmed the new code even boots. If that migration is destructive (a dropped column, a renamed table) and the new release turns out to be broken, rolling back means the old code is now running against a schema it was never written to expect, which usually produces a worse outage than the one the rollback was supposed to fix. The fix is separating the migration step from the deploy step entirely: run migrations as their own reviewable action, keep them backward-compatible with the previous release for at least one deploy cycle, and never let a container's own boot process silently mutate the schema underneath it.

  9. Letting a certificate renewal fail without anyone finding out until it's expired. Renewal is automated by whatever's issuing the cert — Traefik, certbot, Coolify's own integration — and automation that fails silently is worse than no automation, because it removes the instinct to check manually. A DNS record that changed, an ACME rate limit, an expired API token: any of these can break renewal weeks before the certificate actually expires, and nothing surfaces it until real visitors start seeing a browser warning. Let's Encrypt's standard certificates run 90 days and the industry is moving toward 45-day lifetimes, which shrinks that silent window further. The fix is alerting on renewal failure specifically — not just on expiry — and tracking certificate expiry independently of whatever tool is supposed to be renewing it.

  10. Building the app on the same box that's serving production traffic. A build competes for the exact CPU and RAM the running app needs to answer requests, and a build that hangs or OOMs can leave the box in a state where nothing schedules cleanly — including the app that was working fine five minutes earlier. The worst possible moment to discover a broken build is mid-outage on the only server available to fix it. The fix is building in CI or on a separate machine, pushing a finished image to a registry, and letting production do nothing more than pull and run what's already been built and tested elsewhere.

Which of these are data-loss risks, and which are just downtime?

These aren't equally severe, and treating them that way is its own mistake — panicking about a `latest` tag while ignoring an untested backup gets the priorities backwards. Downtime is recoverable by definition: the box comes back, the app comes back, nothing that mattered is actually gone. Data loss isn't recoverable at all once it's happened, which is why the backup-related mistakes sit at the top of any honest severity ranking regardless of how unglamorous they are to write about. The XenGrowth resource library approaches this from the the operations side of this side.

Mistake

What goes unnoticed until it's too late

Actual blast radius

No off-site backup

Everything looks fine until the one copy is gone

Permanent data loss

Untested restore

The backup job reports success for months

Permanent data loss, discovered at the worst possible time

Secrets baked into an image

Nothing, until the image is pulled somewhere it shouldn't be

Credential compromise, potentially silent for a long time

Migrations auto-run on deploy

Fine until the one deploy that needs to roll back

Data corruption or an unrecoverable rollback

Database port published through Docker

Invisible unless someone scans for it first

Data exposure or deletion by whoever finds it first

Unrotated logs filling the disk

Slow build-up over weeks

Downtime — the app looks broken, nothing is actually lost

`latest` tag with no pinned rollback

Fine until the deploy that needs reverting

Downtime, and a longer one than it should be

No swap on a small box

Fine until memory pressure spikes once

Downtime — an abrupt kill, not data loss

Silent cert renewal failure

Invisible until the expiry date arrives

Downtime, user-visible and reputationally ugly

Building on the production box

Fine until one build hangs or OOMs

Downtime, at the worst possible moment to have it

Is there a pattern underneath all ten, or is this just a random list?

There's one pattern, and it's not "self-hosting is dangerous" — it's that every default here was optimized for the five minutes of a tutorial or a demo, not for a server nobody is actively watching at three in the morning. `docker run -p` publishing a port is the fastest way to get a database reachable while you're testing locally, which is exactly why nobody notices it's also the fastest way to get that same database reachable from the entire internet once the box has a public IP. `latest` is the tag every quickstart uses because pinning a version is one more thing to explain. None of this is Docker or Linux being poorly designed — it's software behaving exactly as documented, for an audience that was never the audience running it unattended in production. The prerequisites that make most of this avoidable from day one are covered in the readiness checklist for understanding what you're actually taking on — several of these ten stop being surprises once the underlying mental model is in place. If AI agents and marketing automation is the part you are stuck on, XenGrowth on governed AI marketing workflows is the better reference.

Mistake

Cost to fix before it breaks

Cost to fix after it breaks

Log rotation

Two lines in daemon.json

An emergency disk-cleanup at 2am

Off-site backup

One scheduled job, one bucket

Nothing — there's nothing left to fix

Restore testing

A recurring calendar reminder

Finding out the backup never worked, too late

Pinned image tags

Change one line in a deploy config

Rebuilding the previous release from source, under pressure

Runtime-injected secrets

A different Dockerfile pattern from day one

Rotating every credential that touched the image

So which of these should actually get fixed first?

Start with anything in the data-loss column, because a downtime mistake costs you an afternoon and a data-loss mistake costs you something you can't get back no matter how much time you're willing to spend. Off-site backup and restore testing aren't the most interesting items on this list, and they're the two that actually matter most — everything else here is recoverable by definition, and those two are the exception. Fix the boring ones first. The exciting-sounding ones — the port publishing, the migration ordering — are also real, but they're real in the sense of causing a bad day, not in the sense of causing a bad year. XenGrowth on building one SEO and GEO content system works through AI search, GEO and discovery in more operational detail.

Further reading from XenGrowth

Where this work meets go-to-market

XenGrowth's operator guides writes for the teams who have to run 10 mistakes that break a self-hosted SaaS day to day.

Have you already made one of these?

Five questions drawn from the mistakes in the post. Each one is cheap to fix in advance and expensive to discover in production.

1 / 5
Your uptime monitor runs in a container on the server it watches. What's wrong with that?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

Self-HostingDockerVPSBackupsPostgresDevOpsReliabilitycloud

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

How I Self-Host PostgreSQL for My SaaS (and When I Wouldn't)

Running Postgres in a container is easy. Running it in a way that survives a redeploy, a full disk, and an eventual major-version upgrade is the actual job. Here's the setup, tuned against Postgres's own defaults, and the honest list of where managed wins outright.

Navigate

My Free Monitoring Stack for Self-Hosted Apps

Vercel gives you monitoring whether you ask for it or not. A VPS gives you a blank terminal and the assumption you'll figure it out. Here's what to actually watch on a self-hosted box, with tools that cost nothing, and why watching from the box itself is the one setup that will lie to you.

Navigate

The Hidden Costs of Self-Hosting: A Realistic Monthly Bill

The invoice is the easy half, and it's small. Line it up from published rates, then look at the half no invoice tracks — the migration weekend, the patching, the on-call, the things you now own that used to be someone else's problem.

Navigate

What Happens When Your VPS Runs Out of RAM: The OOM Killer, Explained

There's no warning banner before the kernel kills something. One moment the box is fine, the next a process is dead mid-request — and the process that dies is often not the one that caused the spike. Here's the actual mechanism, and how to read the wreckage afterward.

Navigate

How to Self-Host Next.js With Coolify: A Complete 2026 Walkthrough

Every Coolify tutorial stops at "push to deploy." This one covers the parts that actually break a real app: standalone output, a build that OOMs on a small box, env vars baked in at the wrong time, and a health check that would have caught all of it.

Navigate

The Backup Strategy Every Self-Hosted SaaS Needs (3-2-1, Applied)

3-2-1 is easy to nod along to and easy to get wrong in the specific way that only shows up on the day you need it. Here's what it actually means for one VPS running Postgres and Docker volumes, not the generic version you've already skimmed past twice.

Navigate

My Rule for Deciding What to Self-Host and What to Keep Paying For

Self-hosting everything is a bad idea, and I can point to the exact service where I decided that on purpose. Here's the actual rule I use, not a survey of options — and the one counterexample that explains why the rule exists.

Navigate

The Simplest Production Architecture for a Bootstrapped SaaS

One box, a handful of managed pieces around the edges, and a very short list of things you're not allowed to build yet. Here's the architecture, priced out to $20 a month, and the exact signal that tells you when to add each thing you skipped.

Navigate

How I Secure a Fresh VPS Before Deploying Anything

A brand-new VPS gets scanned within minutes of getting an IP address. Here's the exact order I run through before a single container touches the box — and which of these steps are real protection versus which ones are just theatre.

Navigate

Moving a SaaS Off Vercel to a €5.50 VPS: What the Numbers Actually Look Like

Nobody publishes their real Vercel invoice, so most migration posts trade in vibes instead of arithmetic. This one builds the comparison from Vercel's and Contabo's own published rates, states every assumption out loud, and shows exactly where the two lines cross.

Navigate