Managed Postgres vs self-hosting on a VPS
Managed Postgres or your own VPS: the four jobs that transfer, backups, upgrades, failover and monitoring, and how to work out where the cost crosses over.
What you are deciding
Managed Postgres and Postgres on your own VPS run the same engine, with the same planner and the same pg_dump. You are not buying a better database. You are buying four jobs off your own desk: backups you have restored at least once, minor and major version upgrades, failover, and monitoring that reaches a person who can act. Decide by asking which of those four you will honestly do, then look at the invoice.
For one application with one database on a server you already pay for, self-hosting is usually cheaper by a large multiple, and that multiple does not shrink as the months pass. Managed earns its price the first night the primary dies and nobody on your side is awake. Both sentences are true at once, which is why the answer depends on your team rather than on the technology.
If Postgres is not settled yet, note that a single-writer application below a few hundred writes per second often never needed a server database, and running SQLite in production on a VPS removes all four jobs instead of moving them. The rest of this guide assumes you have chosen Postgres. Every command below uses major version 18 and the Debian and Ubuntu packaging layout, so replace 18 with your own cluster's major version as you read.
The four responsibilities that actually transfer
- Backups. Taking them, storing them off the machine, and proving that a restore produces real rows.
- Upgrades. Minor releases roughly every three months, and one major version per year with a five year support window.
- Failover. Noticing the primary is gone, promoting a standby, and stopping the old primary from ever coming back.
- Monitoring. Disk, write ahead log growth, autovacuum, and an alert path that ends at a human being.
Everything else stays yours. Schema, indexes, query plans, connection handling, and the application's behaviour under load do not change because a vendor holds the root password.
Backups: the only backup that counts is one you have restored
A logical dump is the simplest thing that works, and on a small database it is enough.
sudo -u postgres pg_dump --format=custom --compress=9 --file=/srv/backups/app-$(date +%F).dump app
sudo -u postgres pg_dumpall --globals-only --file=/srv/backups/globals-$(date +%F).sqlThe second line is the one people skip. pg_dump writes one database and nothing outside it, so roles, passwords and tablespaces are not in that file. Restore the dump onto a fresh server without the globals and the application cannot connect at all, with FATAL: role "app" does not exist, because the role that owns every table was never created.
Restoring is not a command you should type for the first time during an outage. Rehearse it into a scratch database on the same box:
sudo -u postgres createdb restore_check
sudo -u postgres pg_restore --dbname=restore_check --no-owner /srv/backups/app-2026-08-29.dump
sudo -u postgres psql -d restore_check -c "select count(*) from orders;"
sudo -u postgres dropdb restore_checkA count close to production means the dump holds rows and not just an empty schema. Run that weekly from cron and alert on a non-zero exit code, and you have replaced a belief with a measurement.
Two traps produce a dump file you cannot use. The first is version skew: pg_dump refuses to read a server newer than itself, and prints pg_dump: error: server version: 18.1; pg_dump version: 16.8 followed by pg_dump: error: aborting because of server version mismatch. Run the dump with client binaries that match or exceed the server, which on Debian and Ubuntu means calling the versioned path under /usr/lib/postgresql/18/bin/ when several majors are installed. The second is storage location: a dump written to the same disk as the data directory protects you from nothing, because one failed volume takes both copies. Push it off the machine on the same schedule, which is what restic with an offsite repository and a retention policy is for.
Why a VPS snapshot is not a database backup
Your provider's snapshot button copies the whole disk while Postgres is running. That gives you a crash consistent image: the same state the disk would have if you pulled the power cord. Postgres is built to survive exactly that, so a snapshot of a single volume usually does restore, after WAL (write ahead log) replay from the last checkpoint. The word "usually" is carrying a lot of weight in that sentence.
It stops working the moment the cluster spans more than one volume. Move pg_wal to a second disk for write throughput, snapshot both, and the two images are taken at different instants. The restored cluster then refuses to start with PANIC: could not locate a valid checkpoint record, and there is no fix except reaching for an older snapshot.
Even when it restores, a snapshot answers the wrong question. It has the granularity of the snapshot schedule, so a DROP TABLE at 14:00 against a nightly image costs you the whole day. It restores a machine and not a table, so recovering one deleted row means booting a second VPS from the image and dumping out of it. It lives in the same provider account as the server it protects, so an account level problem takes the original and the copy together. And it captures corruption as faithfully as it captures data, so a snapshot chain can hold nothing but broken pages if the fault has been present for a week. The full comparison, including the cases where snapshots genuinely earn their place, is in the difference between VPS snapshots and real backups.
Use snapshots for what they are good at: a rollback point taken five minutes before you touch the operating system or run a major upgrade. Keep the database backup separate.
Point in time recovery, and what running it costs you
Point in time recovery (PITR) is the feature people usually mean when they say managed backups are better. It restores the cluster to a chosen second rather than to last night, by replaying archived WAL segments on top of a base backup. Managed services enable it by default and bill for the retention. You can run the same thing yourself with pgBackRest, which is packaged for Debian and Ubuntu.
sudo apt update && sudo apt install -y pgbackrestWrite the stanza in /etc/pgbackrest/pgbackrest.conf:
[global]
repo1-path=/var/lib/pgbackrest
repo1-retention-full=2
start-fast=y
[app]
pg1-path=/var/lib/postgresql/18/mainThen tell the server to hand every finished WAL segment to it, in postgresql.conf:
archive_mode = on
archive_command = 'pgbackrest --stanza=app archive-push %p'wal_level already defaults to replica, which is enough. archive_mode is not: it takes effect on a restart, not on a reload. Create the stanza, take the first full backup, and verify.
sudo systemctl restart postgresql@18-main
sudo -u postgres pgbackrest --stanza=app stanza-create
sudo -u postgres pgbackrest --stanza=app --type=full backup
sudo -u postgres pgbackrest --stanza=app checkcheck forces a WAL switch and confirms the segment lands in the repository. A silent success there is the entire point of the command.
Now the part that makes this an ongoing responsibility rather than a one time setup. If archive_command starts failing, because the repository disk filled or a permission changed, Postgres will not recycle WAL segments that have not been archived. pg_wal grows without limit until the data volume is full, and the cluster stops with PANIC: could not write to file "pg_wal/xlogtemp.1234": No space left on device. A database that was merely failing to back up is now down. That is why archive failure has to be an alert and not a log line. Watch it from cron:
sudo -u postgres psql -Atc "select archived_count, failed_count, last_failed_time from pg_stat_archiver;"A failed_count that climbs between two runs means the archive is broken right now, whatever the last backup said.
Minor and major upgrades: who owns the clock
Postgres ships minor releases on a fixed schedule, roughly every three months, and they contain only bug and security fixes. Applying one is a package upgrade and a restart of a few seconds. Nothing inside the data directory changes.
sudo apt update && sudo apt upgrade
sudo systemctl restart postgresql@18-main
sudo -u postgres psql -tAc "select version();"On managed Postgres these arrive in a maintenance window the vendor picks unless you set one. That is the whole trade in miniature: you do not do the work, and you also do not choose the minute your connections drop.
Major versions are the real deadline. One comes out each year, and each is supported for five years from its release, after which there are no security fixes at all.
The data behind this chart
[
{
"version": "PG 14",
"months_of_support_left": 2
},
{
"version": "PG 15",
"months_of_support_left": 14
},
{
"version": "PG 16",
"months_of_support_left": 26
},
{
"version": "PG 17",
"months_of_support_left": 38
},
{
"version": "PG 18",
"months_of_support_left": 50
}
]Those are the published end of life dates from the PostgreSQL versioning policy, counted forward from August 2026. A cluster still on Postgres 14 has 2 months before the fixes stop, while the newest release here has 50 months. The clock runs at the same speed whichever way you host. What differs is who holds it. A managed provider schedules the upgrade for you and eventually forces it, so you get a date you did not choose. On your own VPS nobody forces anything, which is exactly how clusters end up years past end of life.
On Debian and Ubuntu the upgrade tool is pg_upgradecluster, from postgresql-common. Installing the new major automatically creates an empty cluster, so drop that first or the upgrade has nowhere to put the data.
sudo apt install -y postgresql-18
sudo pg_dropcluster 18 main --stop
sudo pg_upgradecluster -m upgrade 17 main-m upgrade runs pg_upgrade with hard links, which finishes in minutes on a large database instead of the hours a dump and reload takes. It also means the old data directory is no longer safe to start once the new cluster has run, so take your snapshot before this command rather than after. Check the result, then remove the old cluster.
sudo pg_lsclusters
sudo -u postgres psql -tAc "select version();"
sudo pg_dropcluster 17 main --stoppg_lsclusters prints both clusters with their ports and status, so read it before you connect and before you drop anything. Two things break major upgrades in practice. Extensions must exist for the new major before you start, so any CREATE EXTENSION name that came from a third party repository needs its package upgraded first. And planner statistics: before Postgres 18, pg_upgrade carried none across, so the first hours on the new version were slow for a reason that looks exactly like the upgrade breaking performance. Run sudo -u postgres vacuumdb --all --analyze-in-stages immediately afterwards and that symptom never appears.
Failover is a different problem from replication
Building a replica is the easy half, and it is one command. Run these on the standby machine only. The second line deletes a data directory, so read the hostname in your prompt before you press enter.
sudo systemctl stop postgresql@18-main
sudo -u postgres rm -rf /var/lib/postgresql/18/main
sudo -u postgres pg_basebackup -h 10.0.0.10 -U replicator -D /var/lib/postgresql/18/main -R -X stream -C -S standby1 -P
sudo systemctl start postgresql@18-main-R writes standby.signal and the primary_conninfo line for you. -C -S standby1 creates a replication slot on the primary, so the primary keeps WAL that this standby has not consumed yet. That slot is also a hazard: if the standby stays offline, the primary holds WAL forever and fills its own disk, which is the same failure the archive command produces. Check pg_replication_slots and drop slots you have abandoned.
Confirm replication from the primary:
sudo -u postgres psql -xc "select client_addr, state, sent_lsn, replay_lsn from pg_stat_replication;"A row with state set to streaming and replay_lsn close behind sent_lsn is a healthy standby. No row at all means the standby never connected, and its log will say why, usually a missing pg_hba.conf entry covering the replication pseudo-database. Promotion itself is also one command:
sudo -u postgres pg_ctlcluster 18 main promoteThat is where the easy part ends. Failover is not the promote. It is deciding, correctly and quickly, that the primary is really gone rather than briefly unreachable, then guaranteeing it cannot accept writes again, then pointing the application at the new address. Get the first part wrong and you have two servers taking writes with diverging data, and no tool merges that back together for you. Automating the decision properly means Patroni plus a consensus store such as etcd, which is several more services to run and keep patched, on top of the database you were trying to make reliable.
So here is the unpopular recommendation. For a single application, one well backed up VPS with a rehearsed twenty minute restore beats automatic failover you do not fully understand, because half built failover machinery becomes the most likely cause of your next outage. Buy automatic failover from a managed service if you need it, or run one manual standby and accept that promotion is a human decision.
Monitoring: what has to page a human
Managed Postgres includes dashboards and a default alert set. On your own VPS the graphs are the easy part, and postgres_exporter into Prometheus and Grafana gets you them in an afternoon. The hard part is choosing what wakes somebody up, and then proving the message actually arrives.
Five signals deserve an alert on a single application database. Free disk below twenty percent, because Postgres degrades and then stops. WAL directory size growing without falling back, which means archiving or a replication slot is stuck. Replication lag in bytes, if you run a standby. Transaction ID age, because a cluster that exhausts transaction IDs refuses new writes entirely. And the age of your last successful restore rehearsal, which is the only item on this list that tells you whether the backups work.
Transaction ID wraparound is the one most people have never heard of until it happens to them. Autovacuum normally handles it. When it cannot, because a long running transaction or an abandoned replication slot holds the horizon back, the server logs WARNING: database "app" must be vacuumed within 10000000 transactions and eventually stops accepting commands until you vacuum in single user mode. One query watches it:
sudo -u postgres psql -Atc "select datname, age(datfrozenxid) from pg_database order by 2 desc limit 5;"The default autovacuum_freeze_max_age is 200 million, so an age past roughly 150 million on a busy database means autovacuum is losing. Look for old transactions in pg_stat_activity and unused slots in pg_replication_slots, because one of those two is almost always the cause.
What a managed Postgres bill is made of
You cannot compare prices until you know what is being metered. As of August 2026 the major managed Postgres offerings meter at least five separate things, though they use different names for them, and only the first is obvious.
- Compute. Per hour, by instance size. It keeps running while the database is idle, and stopping an instance usually does not stop this meter for long.
- Storage. Per GB month of provisioned space, not of space in use. Storage that has grown often cannot shrink again without a dump and reload into a new instance.
- IOPS and throughput. Sometimes granted in proportion to volume size, sometimes bought separately. A small volume can therefore be slow for a reason that never shows up in the instance size.
- Egress. Bytes leaving the provider's network. An application in the same region and network usually pays nothing here. An application anywhere else pays per GB, and a chatty ORM (object relational mapper) moves far more bytes than people expect.
- Backup retention. Some window is included. Past it you pay per GB month, and long retention on a large database is not a rounding error.
High availability then multiplies the compute line, because a standby is a second instance you are paying for. Each read replica does the same again. Write down which of these five you would really be buying, because a quote for one instance with no standby and short retention is a different product from what most people mean when they say managed Postgres.
The cost method: put both sides in the same units
Do not compare a monthly instance price to a monthly VPS price. Compare cost of ownership per month, and include your own time, because your time is the thing you are considering buying back.
Take the managed quote for the size you actually need, with the standby and retention you actually need, and call it M per month. Size it from measurements rather than from the instance list: peak resident memory of the current Postgres process, peak vCPU during your busiest hour, current data size plus twelve months of growth at the rate you have observed, and your real retention requirement in days.
Now the VPS side. If the server already runs your application, the marginal cost of the database is not the whole VPS bill. It is the RAM you set aside for shared buffers and page cache, the disk the data and WAL occupy, and the object storage your offsite backups consume per GB month. Call that V. On a box you already pay for, V often lands close to the cost of the backup storage alone.
Then the number that decides it. Estimate H, the hours per month you will spend on the four responsibilities: patching and restarts, one major upgrade amortised across the year, the restore rehearsal, and answering alerts. For a single small database that is genuinely low once the automation exists, roughly one to three hours a month, plus one bad day a year or two. Multiply H by R, what an hour of your time is worth to the business.
Managed is the cheaper choice when M minus V is smaller than H times R. Self-hosting is cheaper when it is not. Substitute your own figures. The arithmetic is the deliverable here, not any price I could publish, because vendor pricing moves and the hour count is specific to you.
Two corrections people forget. Egress belongs on the managed side whenever the application does not sit inside the same network as the instance, and it is charged per GB with no ceiling. And H is not constant: it is high in the first month and after any incident, and near zero in a quiet quarter, so estimate it across a year rather than across a good week.
When managed Postgres wins
Nobody is on call. This is the honest reason and it outranks all the others. If the database can stop at 03:00 on a Sunday and nobody on your side is awake or contactable, you are not choosing between two operating models. You are choosing between a vendor's automated failover and an outage that lasts until someone wakes up.
Compliance requires point in time recovery you can evidence. Running PITR yourself is achievable, as the pgBackRest section shows. Producing every year a documented retention policy, an access log for the backup repository, a tested restore report, and a named owner for each of those is a different job, and it is the job the vendor's compliance paperwork already exists to satisfy.
Your team has never rehearsed a restore. If nobody can state the date of the last successful restore, the backups are a hope rather than a control. Managed does not make you immune, since you still have to test the restore button, but its default posture beats an untested pg_dump cron job that has been failing silently since a password changed in March.
The database is about to outgrow one box. Read replicas, a bigger instance next quarter, and storage that grows without a maintenance window are things you buy rather than build. Resizing a VPS means a migration you plan and execute.
The database is the only irreplaceable asset you have and the team is two people. Spend the money. The premium is small next to the value of the thing it protects.
When self-hosting Postgres on a VPS wins
You need an extension the vendor does not offer. Managed Postgres ships an allowlist. CREATE EXTENSION for anything outside it fails, and there is no appeal. Anything that must be loaded into the server process through shared_preload_libraries is restricted twice over, because that parameter is usually not yours to edit. Check the vendor's published list for the exact major version you would run, and do it before you compare prices rather than after you migrate.
You need superuser, or something that quietly requires it. No managed service grants real superuser. You get a role holding a curated subset of its powers. What that removes is specific: untrusted procedural languages such as plpython3u, server side COPY reading a file from the server's own filesystem, functions like pg_read_file, and direct edits to parameters the vendor has reserved. Each has a workaround, the workarounds cost time, and you discover which ones you needed only after the migration.
The application runs on the same box. A Unix socket or a loopback connection has no network hop, no per connection TLS (transport layer security) handshake, and no egress meter. On a chatty workload that is a real difference in latency, and it is free.
You want a predictable bill. A VPS costs the same whether a table holds ten thousand rows or ten million, until you deliberately resize it. A metered instance does not behave that way.
You want control of postgresql.conf and the disk beneath it. Local NVMe on a VPS is frequently faster than the network attached storage a managed instance uses at a comparable price, and you can tune shared_buffers, work_mem, effective_io_concurrency and checkpoint behaviour without waiting for a parameter group to apply.
What neither choice fixes
Managed Postgres does not make a slow query fast. The planner is identical, the indexes are the ones you created, and a sequential scan over ten million rows costs the same on both sides. EXPLAIN (ANALYZE, BUFFERS) is your tool either way.
Connection handling is the other thing people expect to be solved and it is not. Every Postgres connection is a backend process with its own memory, so max_connections is bounded by RAM on any host, managed included. A framework that opens one connection per worker exhausts a managed instance exactly as fast as it exhausts your VPS, and the fix is the same on both: a pooler in transaction mode in front of the database. Sizing that properly has enough detail of its own that it belongs in sizing PgBouncer for Postgres connection pooling on a VPS rather than here.
The same applies to where the process runs once you have decided to self-host. Package or container, data directory on a bind mount or a named volume, and what each choice means for upgrades and backups is a real decision, and it is argued out in running your database in Docker or directly on the host. Whichever you pick, the four responsibilities in this guide stay exactly where they are.
The short version
Price the managed quote properly, price your own hours honestly, then answer one question: when the database stops at three in the morning, who notices, and what do they do? If you have a real answer, self-hosting on a VPS is very likely cheaper, and it stays cheaper. If the honest answer is that nobody notices until a customer emails, self-hosting is not saving you money. It is deferring an outage that you will pay for later.
FAQ
Is a VPS snapshot enough to back up Postgres?
No. A snapshot taken while Postgres runs is a crash consistent disk image, and a single volume image usually does restore after WAL replay. It fails outright when the cluster spans two volumes, because the images are taken at different instants and the restored cluster stops with PANIC: could not locate a valid checkpoint record. Even when it works, it has the granularity of your snapshot schedule, cannot restore a single table, and sits in the same provider account as the server it protects. Take snapshots as a rollback point before operating system changes, and keep pg_dump or pgBackRest output stored off the machine as the actual backup.
Can I get point in time recovery without paying for managed Postgres?
Yes. Install pgBackRest, define a stanza with pg1-path pointing at your data directory, set archive_mode = on and archive_command = 'pgbackrest --stanza=app archive-push %p' in postgresql.conf, restart the server, then run stanza-create, a full backup, and check. The ongoing cost is the monitoring. If archiving starts failing, Postgres keeps every unarchived WAL segment until the data volume is full and the cluster stops with a no space left on device panic, so a rising failed_count in pg_stat_archiver has to raise an alert.
When is managed Postgres actually cheaper than a VPS?
Work it out with three numbers. M is the monthly managed quote for the instance, standby and retention you really need. V is the marginal monthly cost on a VPS you already pay for, which is usually dominated by offsite backup storage. H is the hours per month you will spend on backups, upgrades, failover and monitoring, and R is what an hour of your time is worth. Managed is cheaper when M minus V is less than H times R. On a single small database that inequality rarely holds on cash alone, so the real decision is whether you will do the hours at all.
What can I not do on managed Postgres?
You cannot get real superuser, and you cannot install extensions outside the vendor's allowlist for your major version. That rules out untrusted procedural languages such as plpython3u, server side COPY reading files from the server's own filesystem, functions like pg_read_file, adding libraries to shared_preload_libraries, and editing parameters the vendor has reserved. Check the allowlist for the exact major version before committing, because an extension available on one version of a service is not always available on the next.
How do I upgrade Postgres to a new major version on a VPS?
On Debian and Ubuntu, install the new major package, drop the empty cluster it creates, then run sudo pg_upgradecluster -m upgrade 17 main. The -m upgrade method uses pg_upgrade with hard links and finishes in minutes rather than hours. Take your snapshot before that command, because the old data directory is not safe to start once the hard linked upgrade has run. Afterwards check pg_lsclusters, run your application's own queries against the new cluster, run vacuumdb --all --analyze-in-stages so the planner has statistics, and only then drop the old cluster.