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

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.

Published September 28, 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 does it actually take to self-host PostgreSQL for a production SaaS, and when does managed Postgres win instead?

Self-hosting Postgres in Docker is genuinely fine for a small SaaS, provided you get four things right that the default setup doesn't hand you for free: a named volume so the data survives container recreation instead of the classic anonymous-volume trap, shared_buffers and effective_cache_size tuned off Postgres's own documented starting points rather than left at their conservative defaults, a connection pooler once you're anywhere near the default max_connections ceiling, and a real backup strategy built on WAL archiving rather than a filesystem copy of a live data directory, which Postgres's own documentation says plainly does not work. The genuinely painful part isn't day-to-day operation, it's major version upgrades, which aren't in-place. Managed Postgres earns its markup specifically by taking that list off your plate — automated point-in-time recovery, one-click major-version upgrades, and failover you didn't have to build — and the honest trigger for switching is when the time spent on that list costs more than the price difference.

  • A named volume declared under the top-level volumes: key is what makes Postgres data survive a container recreation — an implicit anonymous volume is the single most common way people lose a self-hosted database
  • shared_buffers defaults to 128 MB in Postgres; the documentation's own starting point for a dedicated database server is 25% of system RAM, up to about 40% before returns diminish
  • effective_cache_size defaults to 4 GB; the commonly cited and documented-consistent starting point is 50-75% of RAM, since it tells the planner how much memory is available for caching, not how much Postgres itself will allocate
  • max_connections defaults to 100, and Postgres's own docs note that raising it increases shared memory usage directly — a connection pooler like PgBouncer solves the actual problem (many app instances, few real backend connections) instead of just raising the ceiling
  • A filesystem-level copy of a running data directory is explicitly not a valid backup per Postgres's own documentation, because tools like tar don't take an atomic snapshot and the server buffers internally — the valid approaches are pg_basebackup, continuous WAL archiving, or a true filesystem-level atomic snapshot that includes the WAL
  • Major version upgrades are not in-place — they require pg_upgrade or a dump/restore, either of which is genuinely the hardest operational task in this whole list, and is a real point in managed Postgres's favor for anyone who'd rather not own that risk

Evidence notes

shared_buffers default and tuning guidance

Default 128 MB; PostgreSQL's own documentation recommends 25% of system RAM as a starting point on a dedicated database server, with returns diminishing past roughly 40%, checked against the current PostgreSQL docs, September 2026.

effective_cache_size default and tuning guidance

Default 4 GB in current PostgreSQL versions (raised from an old 128 MB default); commonly documented starting point is 50-75% of system RAM, since it informs the query planner rather than allocating memory directly, checked September 2026.

max_connections default

Defaults to 100, and PostgreSQL's own documentation states explicitly that increasing it increases shared memory allocation, checked against the current docs, September 2026.

Why a filesystem copy of a live data directory is not a backup

PostgreSQL's own backup documentation states the server must be shut down for a plain filesystem copy to be usable, since tools like tar don't take an atomic snapshot and the server buffers internally; a live filesystem-level backup requires an atomic snapshot including WAL, checked September 2026.

Contabo Cloud VPS 4 pricing

4 vCPU, 8 GB RAM, 100 GB SSD, €5.50/month promotional for the first 24 months, verified on Contabo's own pricing page, checked September 2026.

WAL defaults: max_wal_size and checkpoint_timeout

max_wal_size defaults to 1 GB (the size threshold that triggers an automatic checkpoint) and checkpoint_timeout defaults to 5 minutes, per PostgreSQL's own WAL configuration documentation, checked September 2026.

Continue with purpose

I run Coolify on a Contabo box, and Postgres is a container on that same box. That sentence alone tends to get a reaction from people who've been burned by self-hosting a database before, and the reaction is fair — most of the horror stories about self-hosted Postgres aren't about Postgres being fragile, they're about someone skipping one of four specific things this post covers, and finding out the hard way which one they skipped.

Why did my data disappear after I redeployed the container?

Because the volume holding it was never actually named. This is the single most common way people lose a self-hosted Postgres database, and it's entirely avoidable. Reference a path like /var/lib/postgresql/data in a service's volumes: list without declaring that volume under the top-level volumes: key first, and Compose quietly creates an anonymous volume — one tied to an internal ID, not a name you'd recognize. Recreate the container, and depending on exactly how the recreation happens, you can end up pointed at a fresh, empty volume while your actual data sits orphaned on disk under a name nothing references anymore. If self hosting needs to survive contact with a marketing team, XenGrowth's revenue operations work has the operational side.

  • Declare the volume under the top-level volumes: key with a real name — pgdata:, not an implicit path

  • Reference that exact name in the service definition: volumes: ["pgdata:/var/lib/postgresql/data"]

  • Run docker volume ls before and after any container recreation and confirm the named volume is the one actually mounted

  • Treat an anonymous volume showing up in that list as a warning sign worth investigating immediately, not later

What should shared_buffers and effective_cache_size actually be set to?

Postgres ships conservative on both, on purpose — the defaults have to work on hardware with a fraction of what a modern VPS carries. shared_buffers defaults to 128 MB, which is Postgres's own memory area for caching data pages, and the documentation's own guidance for a dedicated database server is to start at 25% of system RAM, going as high as roughly 40% before you stop seeing benefit. effective_cache_size is a different kind of setting entirely — it doesn't allocate memory itself, it tells the query planner how much memory is realistically available for caching across shared_buffers and the OS page cache combined, which shapes whether it picks an index scan or a sequential scan. It defaults to 4 GB in current Postgres versions, and the commonly cited, documentation-consistent starting point is 50-75% of total RAM on a box where Postgres is the main occupant.

Setting

Documented default

Starting point for a dedicated 8 GB box

What it actually controls

shared_buffers

128 MB

~2 GB (25% of RAM)

Postgres's own cache of data pages in memory

effective_cache_size

4 GB

4-6 GB (50-75% of RAM)

Tells the planner how much cacheable memory exists; doesn't allocate anything itself

max_connections

100

Left at default, fronted by a pooler

Ceiling on concurrent backend connections; each one costs real shared memory

None of these are magic numbers to copy blindly — a box also running the app container and a reverse proxy doesn't get to hand Postgres the full 75%, since the OS and everything else on the box needs headroom too. But starting from Postgres's own documented ranges instead of leaving the out-of-the-box conservative defaults in place is the single highest-leverage tuning change most self-hosted setups skip entirely. It costs nothing but a config edit and a restart, and it's the difference between a database that's using a meaningful fraction of the RAM you're already paying for and one that's still behaving as though it might be running on a machine from a decade ago. The XenGrowth resource library works through the operations side of this in more operational detail.

Do I actually need a connection pooler?

Probably, and earlier than it feels like you do. max_connections defaults to 100, and Postgres's documentation is explicit that raising it isn't free — it increases shared memory allocation directly, which means "just set it to 1000" isn't a solution, it's a different problem. The actual issue a pooler solves is that each application instance, each serverless function invocation, each background worker wants its own connection, and that count climbs fast in a way that has nothing to do with how many queries you're actually running concurrently. A pooler like PgBouncer sits between your app and Postgres, multiplexing many client connections onto a much smaller number of real backend connections, so the app can open connections liberally without Postgres ever seeing anywhere near that number. For a single-box SaaS this is worth wiring in well before you're anywhere near hitting 100 — the failure mode when you do hit it isn't graceful, it's new connections being refused outright.

Does it matter that Postgres is sharing the box with the app itself?

It does, and it's worth being deliberate about it rather than letting both containers fight over the same RAM by default. shared_buffers and effective_cache_size are only sensible settings if Postgres can actually rely on the memory they assume is available — tune them as though Postgres has the whole box to itself while the app container is also running, and the OS ends up arbitrating a resource conflict neither container was configured to expect. Capping the app container's memory explicitly, using the deploy.resources.limits key that Compose enforces natively without Swarm, is what keeps a memory-hungry request in the app from eating into the headroom Postgres's tuning assumed it had. It's a small piece of config, and it's the difference between the tuning numbers above being real guarantees or optimistic guesses.

Is copying the data directory a valid backup?

No, and Postgres's own documentation is unambiguous about this in a way that's worth quoting rather than paraphrasing: the database server must be shut down to get a usable backup from a plain filesystem copy, because tools like tar don't take an atomic snapshot of the filesystem and the server buffers data internally in ways that would leave a live copy inconsistent. A cron job that tars up the data directory while Postgres is running isn't a backup with a small risk attached — it's very likely to be a file you cannot restore from at all, and you won't find out until the day you need it. XenGrowth on governed AI marketing workflows works through AI agents and marketing automation in more operational detail.

There are exactly three approaches that actually work, and the difference matters: pg_basebackup, which takes a consistent base backup while the server keeps running; continuous WAL archiving, which layers point-in-time recovery on top of a base backup by continuously shipping write-ahead log segments somewhere durable; and a true filesystem-level atomic snapshot (the kind some cloud block storage or filesystems support), which works live specifically because it captures the WAL alongside the data directory in one instant rather than copying files one at a time.

  1. Use pg_basebackup or continuous WAL archiving, not a manual file copy of a running instance

  2. Ship the backup off the same box entirely — object storage in a separate account, not a second directory on the same disk

  3. Test the restore path periodically, not just the backup job's exit code — a backup nobody has ever restored from is a hypothesis, not a plan

  4. If you're relying on filesystem snapshots instead, confirm the WAL is included in the snapshot boundary, not just the data directory

What is WAL actually doing, and why does it matter for backups specifically?

The write-ahead log is Postgres recording every change before applying it to the actual data files, which is what makes crash recovery, replication, and point-in-time recovery all possible from the same underlying mechanism. max_wal_size defaults to 1 GB — the threshold that triggers an automatic checkpoint, the point where in-memory changes get flushed to the actual data files — and checkpoint_timeout defaults to 5 minutes, capping how long Postgres will go between checkpoints even if that size threshold isn't hit. Continuous WAL archiving works by shipping every completed WAL segment somewhere durable as it's generated, so a base backup plus the full sequence of archived segments since it was taken can replay the database's state up to almost any specific moment — which is exactly what point-in-time recovery is: not just "restore to last night's backup" but "restore to 2:14pm before the bad migration ran." On AI search, GEO and discovery specifically, XenGrowth on building one SEO and GEO content system is worth reading.

Approach

Safe on a live server?

What it actually gives you

Plain filesystem copy (tar/cp) of a running data directory

No — explicitly documented as unsafe

A file that looks like a backup and very likely isn't restorable

pg_basebackup

Yes — designed for this

A consistent full copy of the cluster at one point in time

Continuous WAL archiving + a base backup

Yes

Point-in-time recovery to almost any moment, not just the last base backup

Filesystem-level atomic snapshot including WAL

Yes, if the snapshot is truly atomic and includes WAL

A fast, storage-layer alternative to pg_basebackup, when the underlying disk supports it

A backup you haven't restored from is a file that exists. Whether it's a backup is a separate, unverified claim.

What's the actual painful part of running this yourself?

Not the day-to-day. Once shared_buffers and effective_cache_size are set sensibly, a pooler is in front, and backups are running against object storage, a self-hosted Postgres instance mostly just runs — it's boring in the way you want infrastructure to be boring. The genuinely hard part is a major version upgrade, and it's hard because it isn't in-place: you can't just swap the container image from postgres:17 to postgres:18 and restart. A major version bump requires either pg_upgrade, which works but has real prerequisites and a real risk of extended downtime on a large database, or a full dump and restore, which is simpler to reason about but means your database is unavailable for however long the dump and restore actually take. There's no version of this that's a one-line config change, and it's the one recurring task on this list I'd genuinely rather not own if the database were significantly larger than what a bootstrapped SaaS is running.

So when does managed Postgres actually win?

Situation

Self-hosted (this setup)

Managed Postgres

Day-to-day operation once tuned

Fine — boring in a good way

Fine, with less setup work up front

Point-in-time recovery

You build and test it yourself against WAL archiving

Usually a built-in feature, a few clicks to restore to a timestamp

Failover on hardware failure

Manual, unless you've built and tested standby promotion yourself

Typically automated, often within seconds

Major version upgrades

pg_upgrade or dump/restore, downtime you own and schedule

Frequently a guided, lower-downtime path the provider has run thousands of times

Cost at small scale

A few dollars of the VPS's own cost, effectively free beyond that

A real line item, often tens of dollars a month minimum even for a small instance

Right for

A small team willing to own the operational list above in exchange for near-zero marginal cost

A team that would rather pay to not think about any of the rows above

The honest trigger for switching isn't a specific database size or traffic number — it's whichever row in that table you'd rather stop owning, weighed against what managed Postgres actually costs on top of the box you're already running. For a bootstrapped SaaS on a Contabo Cloud VPS 4 at €5.50 a month (promotional, first 24 months, per Contabo's own pricing page), self-hosting the database is close to free beyond the box itself, provided the four things above are actually done rather than assumed. That's the argument this whole architecture rests on — covered in full in the simplest production architecture for a bootstrapped SaaS — and it's a genuinely different tradeoff than the one a larger, funded team should be making.

Further reading from XenGrowth

Where this work meets go-to-market

The operational playbooks that sit alongside self hosting live with XenGrowth's growth engineering practice.

Self-host Postgres, or pay someone?

Four questions about what you'd be taking on. The post runs its own Postgres and still thinks plenty of people shouldn't — the deciding factor is rarely the database itself.

1 / 4
Have you ever restored one of your own database backups?

An untested backup is a hypothesis, not a backup.

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

PostgreSQLSelf-HostingDockerDatabaseBackupsSaaS Architecturecloud

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

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.

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

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 Complete Self-Hosted Stack for SaaS in 2026

Contabo, Coolify, Docker, Cloudflare, Postgres, R2, Resend, Uptime Kuma, Turborepo and Docker Hub. Here's every piece of the stack I actually run, what each one replaced, and why I picked it over the alternatives.

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

vCPU or RAM? How to Size a VPS for Static Sites, Databases and Traffic Spikes

Four readers asked four versions of the same question, and each one has a different honest answer. Static files want RAM for page cache, Postgres wants RAM for shared_buffers, and "slow under traffic" is usually neither CPU nor RAM.

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 Security Mistakes I See New Self-Hosters Make

These aren't rare. They're the same seven patterns, documented in breach reports, CVE databases, and botnet postmortems, showing up on new self-hosted boxes on a loop — because the defaults that make setup fast are the same defaults that make a box exploitable.

Navigate

Don't Self-Host Until You Understand These 7 Things

This isn't a gate to keep you out. It's a readiness check — seven things worth being honest with yourself about before you're the one holding the pager, because a managed platform is still the right call for a lot of people right now.

Navigate

Should Your Database Live on the Same VPS as Your App?

The pitch for co-location is real: no network hop, no egress bill, one box to back up. So is the failure mode — one OOM event takes the app and the database down together, because they were never separate to begin with.

Navigate