Both ship with installation defaults that are convenient on a laptop and dangerous on a public server. This tutorial walks the install, the auth and binding adjustments that turn them into something safe to run, and the honest case for when you should give up and pay for managed instead.
"Self-host or managed" is the question that comes before the install commands. Managed Postgres (AWS RDS, Supabase, Neon, Crunchy) and managed Redis (ElastiCache, Upstash) cost more per gigabyte but give you backups, failover, version upgrades, monitoring, and IAM-integrated auth without you running cron jobs. Self-hosted on the same VPS as your app costs almost nothing and is fine for personal projects; it stops being fine the moment downtime would matter or the dataset gets larger than you'd want to lose. The honest break-even is "would a six-hour outage during a database upgrade be acceptable?" β if no, you want managed.
The two services in this tutorial have different default-safety profiles. Postgres's out-of-the-box config on Ubuntu binds to 127.0.0.1 and uses peer authentication (your Unix username has to match the Postgres role), which is genuinely secure by default. Redis's out-of-the-box config also binds to localhost, but if you change the bind address without setting a password you get the worst-case scenario: an open Redis on a public IP, which is one of the most-exploited misconfigurations on the internet. The steps for each reflect this: Postgres just needs a database and a user for your app; Redis needs auth set up before you touch anything network-related.
This tutorial assumes your box has been through the basic hardening checklist β non-root user with sudo, firewall on, SSH key-only. Without that, no amount of database config helps. After the steps, you have a Postgres database with an app-scoped user, a Redis instance with auth enabled, both bound only to localhost (the app and database on the same box), and a backup script for the data that matters.
pg_hba.conf auth methods, what each means, when to use whichpg_dump-to-S3 backup script you can drop into cronReasons self-hosted is fine:
Reasons to pay for managed:
Rough costs at small scale: Supabase free tier (Postgres) supports projects up to 500 MB; Upstash free Redis covers 256 MB. Above that, the cheapest managed Postgres runs $10β25/month; the cheapest managed Redis runs $5β10/month. Self-hosted on an existing VPS is free.
sudo apt update
sudo apt install -y postgresql postgresql-contrib
# Postgres starts automatically. Verify:
sudo systemctl status postgresql
# Active: active (exited) is correct β the wrapper unit is "exited",
# but the actual cluster (postgresql@<ver>-main.service) is running.
# Check the version that landed:
psql --version
Ubuntu 22.04 ships Postgres 14; Ubuntu 24.04 ships 16. For a newer version, add the official Postgres apt repo (apt.postgresql.org) and install postgresql-17 instead. The setup below works on any version >= 12.
Postgres ships with a Unix user postgres that has full database superuser rights via "peer" auth β your Unix user has to match. Switch to it to administer the cluster:
sudo -u postgres psql
You're in the psql prompt as the superuser. Create a role and a database for your app:
-- Replace mybrand_app and the password.
CREATE ROLE mybrand_app WITH LOGIN PASSWORD 'long-random-password-from-pwgen';
CREATE DATABASE mybrand_prod OWNER mybrand_app;
-- Verify:
\du
\l
\q
Generate the password locally with pwgen -s 32 1 or openssl rand -base64 24 β long, random, stored in your password manager. Your app will use it via an env var, never typed by hand.
pg_hba.confThe default pg_hba.conf on Ubuntu has these meaningful lines (paraphrased):
# Unix socket (local pipe, not network) β peer auth (Unix user must match role)
local all postgres peer
local all all peer
# TCP from 127.0.0.1 β scram-sha-256 (password auth)
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256
For an app on the same box connecting to 127.0.0.1:5432, this is already correct β the app authenticates with the password from Step 3. Nothing to change.
If your app connects via Unix socket (no password) instead, change the local all all peer line to:
local all mybrand_app scram-sha-256
local all all peer
This says: the app role uses password auth even on Unix socket; everyone else uses peer auth. Reload Postgres after any change to this file: sudo systemctl reload postgresql.
postgresql://mybrand_app:<password>@127.0.0.1:5432/mybrand_prod. Put this in a .env file your app reads, not in the repo. From Node: process.env.DATABASE_URL; the pg driver parses the URL itself.
Daily snapshot via pg_dump, compressed, shipped to S3 (or any object store). Save as /usr/local/bin/pg-backup.sh:
#!/usr/bin/env bash
set -e
DB=mybrand_prod
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR=/var/backups/postgres
S3_BUCKET=s3://mybrand-backups/postgres
mkdir -p "$BACKUP_DIR"
sudo -u postgres pg_dump -Fc "$DB" | gzip > "$BACKUP_DIR/$DB-$TIMESTAMP.dump.gz"
# Ship to S3. Configure AWS CLI as the deploy user first (aws configure).
aws s3 cp "$BACKUP_DIR/$DB-$TIMESTAMP.dump.gz" "$S3_BUCKET/$DB-$TIMESTAMP.dump.gz"
# Keep 14 days locally; S3 retention is set via lifecycle policy on the bucket.
find "$BACKUP_DIR" -type f -mtime +14 -delete
chmod +x the script. Add a cron entry as the deploy user:
crontab -e
# Add:
30 3 * * * /usr/local/bin/pg-backup.sh >> /var/log/pg-backup.log 2>&1
The harder, more important second step: practice a restore. On a different box, aws s3 cp a recent dump back down, gunzip, pg_restore, verify the row count. A backup you've never restored is hope, not a backup.
sudo apt install -y redis-server
# Service starts automatically, bound to 127.0.0.1 by default. Good.
# Set a password BEFORE doing anything else network-related.
# Generate one:
REDIS_PASSWORD=$(openssl rand -base64 24)
echo "$REDIS_PASSWORD"
# Save it to your password manager. You'll set it as an env var for the app too.
Edit Redis config:
sudo nano /etc/redis/redis.conf
# Find and set:
requirepass <THE_PASSWORD_FROM_ABOVE>
# (Make sure there's NO # at the start of the line.)
# Verify the bind line is:
bind 127.0.0.1 -::1
# (This is the default. Do NOT change to 0.0.0.0 unless you're absolutely
# sure you want Redis exposed to the network, and have the firewall + auth
# set up correctly.)
# Disable the COMMAND command in production (optional, paranoia level):
rename-command CONFIG ""
rename-command FLUSHALL ""
Reload:
sudo systemctl restart redis-server
redis-cli
# Inside redis-cli:
AUTH <the password>
PING # β PONG
exit
Connection string for your app: redis://default:<password>@127.0.0.1:6379. (default is the implicit username when only requirepass is set; if you set up Redis ACLs with named users, the username matters.)
requirepass, gets brute-forced quickly. If your app and Redis are on different boxes, use private VPC IPs, set up a SSH tunnel, or use TLS with client certs (Redis 6+). For most projects: just keep Redis on the same box as the app and bind to 127.0.0.1.
Redis can be used three ways, each with a different persistence story:
save "" in redis.conf, appendonly no. On restart, you start with an empty Redis; the app refills it.save 3600 1 300 100 60 10000 means "after 1 change in an hour, 100 in 5 minutes, or 10000 in a minute, write to disk." Recovery loses anything since the last snapshot. Good for "I'd like data to survive a restart but I can tolerate losing minutes."appendonly yes in redis.conf. Recovery loses at most 1 second of commands (default fsync policy everysec). Use when Redis is your primary store, not a cache.For most app caches: RDB defaults are fine; don't change anything. For session stores or queues: set appendonly yes too. Reload: sudo systemctl restart redis-server.
# Verify Postgres connection (replace credentials):
psql "postgresql://mybrand_app:[email protected]:5432/mybrand_prod" -c "SELECT version();"
# Verify Redis connection:
redis-cli -a "REDIS_PASSWORD" ping # β PONG
# Verify the firewall isn't leaking (from your laptop, not the server):
nc -zv <SERVER_IP> 5432 # β Connection refused (good β Postgres is not public)
nc -zv <SERVER_IP> 6379 # β Connection refused (good β Redis is not public)
If nc from your laptop connects to either port, something's wrong β the service is bound to 0.0.0.0 or the firewall is missing a deny rule. Fix immediately; an open Redis is exploited within hours.
Your database is multi-GB and growing. Backups stop fitting in your overnight window. PITR (point-in-time recovery) starts mattering. Move Postgres to RDS (AWS), Supabase (Postgres-flavored DX), or Neon (serverless, cheap at low traffic).
You need replication or failover. Setting up Postgres streaming replication or Redis Sentinel by hand is a real project. Managed instances do it as a config toggle.
You're running more than one app on the same database. The single-VPS pattern stops scaling when multiple services compete for connections. Move to a dedicated managed instance; your app servers stay simple.
The on-call burden hurts. If "Postgres ran out of disk at 2 AM" has happened once, it'll happen again. Managed instances handle disk auto-grow and alert proactively.