Cheapest Way to Run Postgres Database in 2025

by David Park
Cheapest Way to Run Postgres Database in 2025

What You'll End Up With

By the end of this guide you'll have a production-ready PostgreSQL 16 instance running on a $4–6/month Hetzner CAX11 (or equivalent) ARM VPS, locked down with a non-default port, a dedicated database user, and automated daily backups to an S3-compatible bucket. Total monthly cost: under $7 including storage. This is the cheapest way to run a Postgres database that still holds up in production.

Prerequisites:

  • A fresh Ubuntu 24.04 VPS (Hetzner CAX11, Vultr, or DigitalOcean — 2 GB RAM minimum recommended)
  • A non-root user with sudo privileges
  • SSH access to the server
  • An S3-compatible bucket (Backblaze B2 free tier covers 10 GB)
  • Basic familiarity with systemctl and psql

Why Self-Host Instead of a Managed Service?

Managed Postgres from AWS RDS, Supabase, or Render starts at $15–25/month for the smallest tier. For a bootstrapped project doing a few hundred requests per day, you're paying for overhead you don't need. A $4 Hetzner CAX11 ARM instance gives you 2 vCPUs, 4 GB RAM, and 40 GB NVMe — more than enough for a early-stage SaaS or side project. The tradeoff is that you own the operations. This guide makes that tradeoff manageable.


Step 1 — Provision and Harden the VPS

1.1 Update the package index and apply security patches immediately after first login.

sudo apt update && sudo apt upgrade -y

1.2 Install ufw and allow only SSH and your chosen Postgres port (we'll use 5432 internally but restrict external access entirely).

sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw enable

Expected output:

Firewall is active and enabled on system startup

1.3 Disable password authentication in SSH to close the most common attack vector.

sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl restart ssh

Step 2 — Install PostgreSQL 16

2.1 Add the official PostgreSQL APT repository. Ubuntu 24.04's default repos ship Postgres 16, but pinning the official repo ensures you get point releases faster.

sudo apt install -y curl ca-certificates
sudo install -d /usr/share/postgresql-common/pgdg
curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail \
  https://www.postgresql.org/media/keys/ACCC4CF8.asc
sudo sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] \
  https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
  > /etc/apt/sources.list.d/pgdg.list'

2.2 Install PostgreSQL 16.

sudo apt update && sudo apt install -y postgresql-16

2.3 Confirm the service is running.

sudo systemctl status postgresql@16-main

Expected output (truncated):

● postgresql@16-main.service - PostgreSQL Cluster 16-main
     Loaded: loaded (/usr/lib/systemd/system/postgresql@.service; enabled)
     Active: active (running)

Step 3 — Create a Database and Dedicated User

Never use the postgres superuser for application connections. Create a scoped user and database.

3.1 Switch to the postgres system user and open psql.

sudo -u postgres psql

3.2 Inside psql, run the following block. Replace yourapp, youruser, and StrongPassw0rd! with your own values.

CREATE DATABASE yourapp;
CREATE USER youruser WITH PASSWORD 'StrongPassw0rd!';
GRANT ALL PRIVILEGES ON DATABASE yourapp TO youruser;
ALTER DATABASE yourapp OWNER TO youruser;
\q

3.3 Verify the database and user exist.

sudo -u postgres psql -c "\l"

Expected output includes a row:

 yourapp   | youruser | UTF8 ...

Step 4 — Tune postgresql.conf for a Low-Memory VPS

Default PostgreSQL settings are conservative. On a 4 GB RAM VPS, apply these targeted changes to squeeze out real performance.

4.1 Open the configuration file.

sudo nano /etc/postgresql/16/main/postgresql.conf

4.2 Find and update the following lines (uncomment if prefixed with #):

shared_buffers = 1GB
effective_cache_size = 3GB
maintenance_work_mem = 256MB
work_mem = 16MB
max_connections = 50
wal_buffers = 16MB
checkpoint_completion_target = 0.9
random_page_cost = 1.1
effective_io_concurrency = 200

Rule of thumb: shared_buffers = 25% of RAM, effective_cache_size = 75% of RAM.

4.3 Reload Postgres to apply changes without downtime.

sudo systemctl reload postgresql@16-main

Step 5 — Restrict Network Access

Do not expose port 5432 to the public internet. Applications on the same server connect via the Unix socket. Remote applications connect over an SSH tunnel.

5.1 Confirm Postgres listens only on localhost.

sudo grep -E '^listen_addresses' /etc/postgresql/16/main/postgresql.conf

Expected output:

listen_addresses = 'localhost'

If it shows '*', change it to 'localhost' and reload.

5.2 For a remote application (e.g., your app server), create an SSH tunnel instead of opening the port.

ssh -N -L 5432:localhost:5432 youruser@your-vps-ip

Your app then connects to localhost:5432 as if Postgres were local. Add -f to background the tunnel, or use autossh for a persistent tunnel in production.


Step 6 — Set Up Automated Daily Backups

This is the step most people skip and regret. Backups to an S3-compatible bucket (Backblaze B2 free tier: 10 GB free) cost nearly nothing.

6.1 Install aws-cli (works with any S3-compatible endpoint).

sudo apt install -y awscli

6.2 Configure credentials. Use Backblaze B2 Application Key ID and Application Key.

aws configure

Enter your Key ID, Secret Key, region (us-east-005 or your B2 region), and output format (json).

6.3 Create the backup script at /usr/local/bin/pg-backup.sh.

sudo nano /usr/local/bin/pg-backup.sh

Paste the following. Replace yourapp, your-bucket-name, and the endpoint URL.

#!/usr/bin/env bash
set -euo pipefail

DB_NAME="yourapp"
BUCKET="s3://your-bucket-name/postgres"
ENDPOINT="https://s3.us-east-005.backblazeb2.com"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="/tmp/${DB_NAME}_${TIMESTAMP}.sql.gz"

sudo -u postgres pg_dump "${DB_NAME}" | gzip > "${BACKUP_FILE}"

aws s3 cp "${BACKUP_FILE}" "${BUCKET}/${DB_NAME}_${TIMESTAMP}.sql.gz" \
  --endpoint-url "${ENDPOINT}"

rm -f "${BACKUP_FILE}"

# Delete backups older than 30 days from the bucket
aws s3 ls "${BUCKET}/" --endpoint-url "${ENDPOINT}" | \
  awk '{print $4}' | \
  while read -r key; do
    date_str=$(echo "${key}" | grep -oP '\d{8}')
    if [[ $(date -d "${date_str}" +%s 2>/dev/null) -lt $(date -d '30 days ago' +%s) ]]; then
      aws s3 rm "${BUCKET}/${key}" --endpoint-url "${ENDPOINT}"
    fi
  done

echo "Backup complete: ${BACKUP_FILE}"

6.4 Make the script executable.

sudo chmod +x /usr/local/bin/pg-backup.sh

6.5 Schedule it with cron to run at 02:00 UTC daily.

sudo crontab -e

Add this line:

0 2 * * * /usr/local/bin/pg-backup.sh >> /var/log/pg-backup.log 2>&1

Verify It Works

Run each check before you ship anything to production.

Check 1 — Postgres is accepting connections:

psql -h localhost -U youruser -d yourapp -c "SELECT version();"

Expected output:

 PostgreSQL 16.x on aarch64-unknown-linux-gnu ...

Check 2 — Firewall is not exposing port 5432:

sudo ss -tlnp | grep 5432

Expected output:

LISTEN 0 128 127.0.0.1:5432 0.0.0.0:*

If you see 0.0.0.0:5432 without the 127.0.0.1 prefix, Postgres is listening on all interfaces — fix listen_addresses immediately.

Check 3 — Run a manual backup and confirm the file appears in B2:

sudo /usr/local/bin/pg-backup.sh
aws s3 ls s3://your-bucket-name/postgres/ --endpoint-url https://s3.us-east-005.backblazeb2.com

Troubleshooting

psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed The Postgres service is not running. Run sudo systemctl start postgresql@16-main and check sudo journalctl -u postgresql@16-main -n 50.

FATAL: password authentication failed for user "youruser" Verify the password in pg_hba.conf. Run sudo nano /etc/postgresql/16/main/pg_hba.conf and confirm the local/host entries use scram-sha-256 or md5, not peer.

Backup script exits with Unable to locate credentials The cron environment doesn't inherit your AWS config. Add AWS_SHARED_CREDENTIALS_FILE=/root/.aws/credentials at the top of the script, or store credentials in /root/.aws/config explicitly.

High memory usage after tuning If the VPS OOM-kills Postgres, reduce shared_buffers to 512MB and work_mem to 8MB. On a 2 GB RAM VPS, set shared_buffers = 512MB and effective_cache_size = 1536MB.

Slow queries after migration Run ANALYZE; after large imports. Missing statistics cause the planner to pick bad query plans.


Cost Breakdown

Item Monthly Cost
Hetzner CAX11 (ARM, 4 GB RAM) $4.15
Backblaze B2 storage (< 10 GB) $0.00
Backblaze B2 egress (restore) $0.00 (first 3x storage free)
Total ~$4.15/month

Compare that to $25/month for the smallest RDS db.t4g.micro with Multi-AZ, or $15/month for a managed Postgres starter plan elsewhere. For a bootstrapped project, the savings compound fast.


Next Steps

Once this setup is stable, consider these additions in priority order:

  1. Connection pooling — Install PgBouncer (sudo apt install -y pgbouncer) in front of Postgres. Keeps max_connections low and handles spiky traffic from serverless functions.
  2. Monitoring — Install pg_activity (sudo apt install -y pg-activity) for a real-time query monitor, or ship metrics to Grafana Cloud's free tier via postgres_exporter.
  3. Streaming replication — When your project grows, add a second $4 VPS as a streaming replica for read scaling and failover. The cheapest way to run a Postgres database at higher availability is still two cheap VPSes, not one managed service.
  4. Upgrade path — When you outgrow 4 GB RAM, resize the Hetzner server in place. No data migration required.