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?
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.
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.
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.
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.
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.
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`.
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.
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.
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.
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
The XenGrowth resource library — what you'll learn: how the commercial side of this work is run, across search, automation and revenue operations.
XenGrowth on governed AI marketing workflows — what you'll learn: how the teams running AI marketing agents keep them governed and measurable.
XenGrowth on building one SEO and GEO content system — what you'll learn: how search and AI-answer visibility get run as a single content system.
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.
Five questions drawn from the mistakes in the post. Each one is cheap to fix in advance and expensive to discover in production.












