Why self-host PostgreSQL on a VPS
Managed databases (RDS, Cloud SQL) bill per GB stored, per IOPS and per connection, and often limit available extensions. By deploying PostgreSQL on your own VPS, you enable pg_stat_statements, PostGIS, pgvector or TimescaleDB without restriction, choose your exact version (15, 16, 17) and keep your data in a jurisdiction you control. A VPS with an SSD disk offers very low I/O latencies, ideal for the transactional workloads of a production application. You pay a fixed and predictable flat rate, and you alone decide on your strategy for replication, encryption and backup retention.
The concrete benefits of a self-hosted PostgreSQL
- Fixed and predictable cost: no billing per GB, per IOPS or per number of active connections.
- Free extensions:
pgvectorfor AI,PostGISfor geospatial,TimescaleDBfor time series. - Full control of backups: scheduled
pg_dump,pg_basebackupor physical replication to a second VPS. - Fine tuning of
postgresql.conf(shared_buffers, work_mem, WAL) impossible with most managed offerings. - Data sovereignty: your data stays on an infrastructure you choose.
- Real superuser access to audit, profile and optimize without a support ticket.
Hardware and software prerequisites
For a development PostgreSQL instance or a small production project, plan for a VPS with 2 vCPU, 4 GB of RAM and 40 GB of NVMe SSD: PostgreSQL likes memory for its page cache. For a heavier load (several hundred connections or datasets of several tens of GB), aim for 4 vCPU and 8 GB of RAM. On the software side: Ubuntu 22.04 or 24.04 LTS, Docker Engine and the Docker Compose v2 plugin, a domain name pointing to the VPS (e.g. db.mydomain.com) if you expose remote access, and a ufw firewall configured to let through only the strict minimum. Also reserve a dedicated Docker volume for PGDATA to decouple the data from the container.
Deploy PostgreSQL with Docker in production
Prepare the VPS and the firewall
Connect via SSH, update the system with
apt update && apt upgrade -y, install Docker viacurl -fsSL https://get.docker.com | sh. Block port 5432 from the outside withufw deny 5432: the database must never be directly exposed to the Internet.Write the docker-compose.yml
Create a
postgres:17service, mount a named volume on/var/lib/postgresql/data, and definePOSTGRES_PASSWORD,POSTGRES_DBandPOSTGRES_USERvia a.envfile that is never committed. Addshm_size: 256mbfor large sorts and ahealthcheckwithpg_isready.Launch and initialize the database
Start with
docker compose up -d, check the logs viadocker compose logs -f postgres, then connect withdocker compose exec postgres psql -U app -d mabase. Create your application roles with minimal privileges rather than using the superuser.Expose access via a tunnel or a TLS proxy
For secure remote access, never open 5432 in the clear: use an SSH tunnel (
ssh -L 5432:localhost:5432) or placepgbouncerbehind a reverse proxy. Enable SSL on the PostgreSQL side with a Let's Encrypt certificate mounted in the container (ssl = oninpostgresql.conf).Tune the configuration
Adjust
shared_buffersto about 25% of the RAM,effective_cache_sizeto 50-75%, andwork_memaccording to your queries. Enablepg_stat_statementsto identify slow queries. Use the PGTune tool as a starting point.Automate the backups
Schedule a daily
pg_dumpvia a cron task on the host, compress into custom.dumpformat, and copy the archives to external storage. Regularly test restoration withpg_restore: an untested backup does not exist.Logging in for the first time
pgAdmin is not published on the internet: open a tunnel from your own machine (ssh -L 5050:127.0.0.1:5050 root@<IP>), then go to http://127.0.0.1:5050 and sign in with [email protected] and the password provided. Then add your server ("Add New Server") with host db, port 5432, user postgres and that same password.
Configure postgresql.conf for production
work_mem and max_connections
Set
work_membased on your sort and join queries: 4 to 16 MB is reasonable for mixed workloads. Note: each connection can allocatework_memmultiple times simultaneously, so multiply bymax_connectionsto estimate peak memory usage. For 100 application connections,max_connections = 150(reserve for monitoring tools and migrations) is plenty; beyond that, usepgbouncerin transaction mode to multiplex connections.Logging and slow query detection
Enable
logging_collector = on,log_min_duration_statement = <seuil_ms>(ms) andlog_line_prefix = '%t [%p]: [%l-1] db=%d,user=%u 'to log all queries exceeding the configured threshold. Logs land inpg_log/inside the PGDATA volume. Pairing this withpg_stat_statements(extension to enable inshared_preload_libraries) gives you an aggregated view of the most expensive queries without scanning each log file.WAL and checkpoints
Increase
wal_buffersto 16 MB and setcheckpoint_completion_targetto 0.9 to spread checkpoint writes over time. On NVMe SSD,random_page_cost = 1.1(instead of the default 4.0) encourages the planner to choose index scans over seq scans — a frequently overlooked setting that dramatically changes execution plans on large datasets.
Automated backups and recovery strategy
An untested backup is a backup that does not exist. Three complementary approaches cover different needs.
pg_dump + cron is the starting point: simple to set up, it produces a logical SQL file restorable on any compatible version. Schedule it at 3 AM, compress with --format=custom (compressed binary format, selective restore by table), and copy the archive to external storage (S3-compatible object, another VPS) with rclone or scp. Keep 7 days locally and 30 days remotely.
pgBackRest goes further: it supports incremental and differential backups, parallel compression and point-in-time restore (PITR) from WAL archives. It is the recommended solution once your database exceeds a few GB or you need to rewind to a precise minute after an accidental DELETE. It is configured via a pgbackrest.conf file and integrates with a cron job or systemd timer.
Monthly restore test: regularly run pg_restore -d test_restore <dump> on a test VPS to verify archive integrity. A weekly script that restores the latest backup and checks a table count or a control query is enough to catch silent corruption before it becomes critical.
Lightweight high availability without Kubernetes
PostgreSQL high availability on a VPS does not necessarily require Patroni or a complex Pacemaker cluster. Native streaming replication covers most needs with minimal configuration.
Manual streaming replication: on the primary VPS, enable wal_level = replica, add a dedicated replicator user in pg_hba.conf and set max_wal_senders = 3. On the secondary (standby) VPS, run pg_basebackup to initialize a physical copy, create a standby.signal file and configure primary_conninfo in postgresql.conf. Replication starts automatically on restart. With hot_standby = on, the standby accepts read-only queries — perfect for offloading reports and monitoring.
Patroni adds automatic failover: it monitors the primary via a key in etcd, Consul or ZooKeeper, and automatically promotes the standby if the primary becomes unreachable for more than 30 seconds. On two VPS, a third-party etcd (lightweight VM or managed service) for consensus is sufficient. Patroni is the recommended path when you cannot tolerate a 5-to-15-minute manual intervention during a failure.
Replication lag monitoring: SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn) AS lag_bytes FROM pg_stat_replication on the primary gives you the real-time lag in bytes. A lag above a few MB indicates network saturation or an overloaded standby.
PostgreSQL self-hosted vs RDS vs Supabase vs Neon
Scroll the table
| Criterion | ServOrbit VPS (self-hosted) | AWS RDS PostgreSQL | Supabase (cloud) | Neon (serverless) |
|---|---|---|---|---|
| Monthly cost (small) | 99 DH/month (flat rate) | ~$35-70/month (db.t3.medium) | ~$25/month (Pro) | ~$19/month (Launch) |
| postgresql.conf control | Full | Partial (parameter groups) | Limited | Minimal |
| Custom extensions | All (pgvector, PostGIS…) | Restricted list | pgvector, PostGIS | Limited |
| PITR (Point-in-Time Recovery) | Yes with pgBackRest | Yes (native) | Yes (Pro+) | Yes (native) |
| High availability | Manual replication or Patroni | Multi-AZ native | Native | Multi-region native |
| Maintenance burden | High (DBA required) | Low | Very low | Very low |
| Data outside US | Yes (Moroccan VPS) | By region | By region | US/EU only |
| Superuser / extensions | Yes | No | No | No |
Troubleshooting common production issues
OOM killer kills PostgreSQL: the OOM signal in /var/log/syslog (oom_kill_process) means memory is exhausted. Main causes: shared_buffers set too high, work_mem miscalibrated against connection count, or a concurrent process (Elasticsearch, Node.js) consuming all swap. Diagnosis: cat /proc/meminfo | grep -E 'MemFree|Cached|Buffers' and ps aux --sort=-%mem | head -10. Fix: reduce work_mem, enable huge_pages = try in postgresql.conf, or upgrade the VPS.
Connections exhausted (FATAL: sorry, too many clients already): max_connections is reached. Short term: SELECT count(*), state FROM pg_stat_activity GROUP BY state to identify idle connections. Long term: deploy pgbouncer in transaction mode (pool_mode = transaction) in front of PostgreSQL — it multiplexes hundreds of application connections onto a pool of 20-50 real connections.
Autovacuum saturated: tables with many UPDATE or DELETE operations accumulate dead tuples that bloat tables and slow down scans. SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10 reveals the problematic tables. Speed up autovacuum by reducing autovacuum_vacuum_scale_factor (e.g. 0.01 instead of 0.2) for critical tables via ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.01).
Checkpoints too frequent (LOG: checkpoint occurring too frequently): WAL fills faster than checkpoint_timeout (default 5 min). Increase max_wal_size (e.g. 2-4 GB) and checkpoint_timeout to 15-30 min to space out checkpoints without risking excessive data loss on a crash.
For high availability, deploy a second VPS in physical streaming replication (primary_conninfo + replication slot). You get a warm standby ready to fail over, and you can offload your heavy read queries to it (hot_standby = on) to relieve the primary. Combined with pgBackRest for differential incremental backups, you reach an RPO of a few minutes without depending on a managed service.
The official documentation
For advanced configuration, tool-specific options and version changes, refer to the official PostgreSQL documentation. This guide covers going live on a ServOrbit VPS; the vendor documentation remains the reference for fine tuning.