TL;DR
Co-locating Postgres with your app is the right default until three things happen at once: disks are pinned at their IOPS ceiling, the OOM killer starts visiting, and your slow query log fills up with queries that should be sub-millisecond. Before splitting, exhaust shared_buffers tuning and a local PgBouncer; those two changes fix more "we need to scale" tickets than any hardware move. When you do split, the real cost on Hetzner is roughly an extra 4-9 EUR/month for a dedicated DB box, which is almost always cheaper than the next CPU tier on a single host.
The default: one box is fine, until it isn't
For most self-hosted open-source apps — Gitea, Plausible, Mastodon, Outline, n8n, a Rails monolith — a single VM running both the web tier and Postgres is the correct architecture. It is cheaper, has lower latency (Unix sockets beat any network), is easier to back up, and removes an entire class of failure modes (network partitions between app and DB). A Hetzner CX22 at ~4.5 EUR/month or a DigitalOcean basic droplet at $6/month will comfortably run a small SaaS-style app with a few thousand users on it.
The mistake is not starting here. The mistake is staying here when the box is screaming at you. Below are the specific symptoms that mean "split now," ordered by how unambiguous they are.
Symptom 1: IOPS contention
Shared cloud VMs have surprisingly tight IOPS budgets. A Hetzner CX22's local NVMe will burst high but sustains far less under continuous mixed load; DigitalOcean basic droplets are capped well below their premium tiers; Linode Nanodes share spindles with neighbors. When Postgres and your app both want disk, Postgres usually loses — because the app's syscalls are smaller and more frequent, and the kernel's CFQ-ish behavior under load is not friendly to a process doing 8 KB random reads all day.
What this looks like in metrics:
iostat -x 5shows%utilpegged at 95-100% on the data volume.awaitis consistently above 20 ms on what should be NVMe-class storage.pg_stat_database.blks_readis climbing fast relative toblks_hit— your cache hit ratio is dropping below ~95%.vmstat 1showswa(iowait) routinely above 15-20%.
If you see all four, your DB is starved for disk and your app is making it worse. Before moving boxes, check whether you can simply give Postgres more RAM (next section) so it stops reading from disk in the first place. If the working set genuinely exceeds RAM, splitting tiers is the only real fix — a dedicated DB VM gets its own IOPS budget instead of fighting nginx for it.
Symptom 2: OOM kills
The Linux OOM killer is the loudest possible signal that you have outgrown a single box, and it almost always picks the wrong process. Postgres backends have large RSS values because of shared_buffers, so the OOM killer loves them. When the kernel kills a Postgres backend, the postmaster restarts the whole cluster to be safe, which means every connection drops, every in-flight transaction rolls back, and your app's connection pool spends the next 30 seconds reconnecting and panicking.
Check for it:
dmesg -T | grep -i 'killed process'
journalctl -k | grep -i oom
One OOM in six months is forgivable — somebody ran a runaway report. OOMs once a week mean you are oversubscribed. The temptation is to add swap; resist it. Swapping a Postgres backend is worse than killing it, because the latency hit cascades into lock waits and the whole DB grinds. Either give it more RAM (vertical scale) or split the tier (horizontal scale across two smaller boxes).
Symptom 3: slow query log full of trivial queries
This is the subtle one. Turn on log_min_duration_statement = 200 for a day and read the log. If your slow queries are honest-to-god analytical queries — joins across millions of rows, missing indexes, SELECT * from a 50 GB table — that is a query optimization problem, not an architecture problem. Fix the queries.
But if your slow log is full of things like SELECT * FROM users WHERE id = $1 taking 400 ms, or BEGIN taking 80 ms, your database is not slow — it is blocked. The query itself runs in microseconds; the latency is the time spent waiting for a CPU slice, a lock, or a connection. That waiting is almost always caused by the app tier stealing CPU, or by too many connections fighting over the same backends. Splitting tiers fixes the first; PgBouncer fixes the second.
Before you split: two changes that buy you 6-12 months
Tune shared_buffers properly
The default shared_buffers = 128MB is a 2005 number. On any modern VM with 4 GB or more, set it to roughly 25% of RAM, and effective_cache_size to about 50-75% of RAM. On a CX22 (4 GB), that is shared_buffers = 1GB, effective_cache_size = 2.5GB. On a CX32 (8 GB), 2 GB and 5 GB respectively.
Also worth setting:
| Parameter | Default | Reasonable value on 8 GB box |
|---|---|---|
shared_buffers |
128 MB | 2 GB |
effective_cache_size |
4 GB | 5 GB |
work_mem |
4 MB | 16-32 MB |
maintenance_work_mem |
64 MB | 512 MB |
wal_buffers |
-1 (auto) | 16 MB |
max_wal_size |
1 GB | 4 GB |
checkpoint_completion_target |
0.9 | 0.9 (keep) |
random_page_cost |
4.0 | 1.1 (NVMe) |
That last one matters more than people think. random_page_cost = 4.0 was calibrated for spinning rust. On NVMe, leaving it at 4 makes the planner prefer sequential scans when an index would be faster. Drop it to 1.1.
After these changes, restart and watch the cache hit ratio for a week. If it climbs above 99% and stays there, you have probably bought yourself another six months on the same box.
Put PgBouncer in front, even on a single host
PgBouncer in transaction pooling mode is the single highest-ROI piece of infrastructure in the Postgres ecosystem. Every framework (Rails, Django, Node, Phoenix) opens way more connections than Postgres can efficiently handle, because each connection eats 5-15 MB of RAM and a process slot. A pool of 200 app-side connections multiplexed onto 25 Postgres backends will outperform 200 direct connections by a large margin, especially under burst load.
Install it on the same box. Point your app at localhost:6432 instead of localhost:5432. Set pool_mode = transaction, default_pool_size = 25, max_client_conn = 500. Done. You have just removed the most common cause of "the database is slow" complaints, and you have made a future split painless — when you do move Postgres to its own VM, you only change the PgBouncer config, not the app.
The one gotcha: transaction pooling breaks session-level features (prepared statements in some clients, SET LOCAL, advisory locks held across statements). Most modern ORMs handle this fine; check yours.
When to actually split, and how
If you've tuned shared_buffers, you have PgBouncer in front, and you are still seeing the symptoms above, it is time. The minimum viable split:
- Provision a second VM in the same datacenter/region. Same provider, same DC — you want sub-millisecond network latency. Hetzner's private networks and DigitalOcean VPCs are both free.
- Install Postgres, copy your
postgresql.conf, restore from apg_basebackupor logical dump. - Lock the app, switch the connection string (or PgBouncer's upstream), unlock.
- Keep PgBouncer on the app box, not the DB box. The pool benefits from being close to the client.
Total downtime if you've practiced: 2-5 minutes. If you haven't practiced, an hour. Practice on staging.
Do you need a replica?
Not yet. A streaming replica doubles your DB cost and adds operational complexity (failover, lag monitoring, split-brain risk). Add one when either: (a) you have a real read-heavy workload that benefits from read replicas, or (b) you have an SLA that requires sub-minute recovery. For most self-hosted apps, nightly pg_basebackup to object storage plus WAL archiving to S3/Spaces gives you a 5-15 minute RPO and is dramatically simpler.
If you do add a replica, use physical streaming (primary_conninfo + standby.signal) for HA, and logical replication only for things like zero-downtime major-version upgrades.
The cost delta in 2026 prices
Concrete numbers, monthly, list price:
| Setup | Hetzner | DigitalOcean | Linode |
|---|---|---|---|
| Single CX32 / 4 vCPU 8 GB | ~7.5 EUR | $48 (Premium AMD) | $36 (Dedicated 8GB) |
| Split: small app + small DB | ~4.5 + 7.5 = 12 EUR | $12 + $48 = $60 | $12 + $36 = $48 |
| Split: small app + DB with replica | ~4.5 + 7.5 + 7.5 = 19.5 EUR | $12 + $48 + $48 = $108 | $12 + $36 + $36 = $84 |
On Hetzner the delta is so small (~4.5 EUR/month to split, ~12 EUR for HA) that the conversation is basically "do it whenever you feel like it." On DigitalOcean and Linode the delta is more meaningful, but still cheaper than jumping to the next CPU tier on a single host — a 4 vCPU droplet is $48, a 8 vCPU droplet is closer to $96, so a $60 split is the better deal once the single box is saturated.
You can sanity-check any of these against your actual workload using the osscostcalc calculator on this site; it will show the three providers side by side with current list prices including egress and snapshot costs, which are easy to forget.
The hidden costs
- Snapshots: now you need them on two volumes, not one. Budget another 1-3 EUR/month on Hetzner, more on DO.
- Backups to object storage: roughly $0.02/GB/month on most providers' object stores. A 50 GB DB with 30 days of nightly base backups + WAL is maybe $5-10/month.
- Monitoring: a single Netdata or Prometheus node now scrapes two hosts. No real money, but more YAML.
Decision checklist
Use this before you move anything:
-
iowaitconsistently above 15% under normal load? -> tier split likely needed - OOM kills in
dmesgwithin the last 30 days? -> tier split or vertical scale, now - Cache hit ratio below 95% after tuning
shared_buffers? -> working set exceeds RAM, split - Slow query log dominated by trivial lookups? -> contention, split
- Have you actually tuned
shared_buffersto ~25% of RAM? -> if no, do this first - Is PgBouncer in front of Postgres? -> if no, do this first
- Are backups tested and a restore drill done in the last 90 days? -> do this before any migration
- Have you priced both the split and the next vertical tier in the calculator? -> compare honestly
If you tick the first four and have already done five and six, provision the second VM this week. Don't wait for the outage that forces your hand at 2 AM on a Saturday.