PostgreSQL streaming replication keeps one or more standby servers continuously in sync with a primary by shipping Write-Ahead Log (WAL) records over a network connection. The standby replays those records, can serve read-only queries, and can be promoted to primary if the original server fails.
In this tutorial you will set up asynchronous streaming replication between two Ubuntu 24.04 servers running PostgreSQL 16, protect it with a replication slot, verify that data flows, measure replication lag, and practice a manual failover. An optional section shows how to switch to synchronous replication.
Prerequisites
To follow this tutorial, you will need:
- Two servers running Ubuntu 24.04 LTS, for example two CubePath VPS in the same location. This guide calls them primary and standby.
- A non-root user with
sudoprivileges on both servers. - A private network between the servers (recommended) or public IPs. The examples use
primary_ip(10.0.0.10) andstandby_ip(10.0.0.11); replace them with your own addresses. - Enough disk on the standby to hold a full copy of the primary's data directory.
Both servers must run the same PostgreSQL major version. Physical streaming replication does not work across major versions (for example 15 to 16).
Step 1 - Installing PostgreSQL on both servers
Ubuntu 24.04 ships PostgreSQL 16 in its main repository. Run the following on both servers:
sudo apt update
sudo apt install -y postgresql
Check that the cluster is running:
pg_lsclusters
Ver Cluster Port Status Owner Data directory Log file
16 main 5432 online postgres /var/lib/postgresql/16/main /var/log/postgresql/postgresql-16-main.log
On Ubuntu, configuration files live in /etc/postgresql/16/main/ and data lives in /var/lib/postgresql/16/main/. Keep that split in mind: you will edit files in /etc and replace the data directory on the standby.
Step 2 - Configuring the primary server
All steps in this section run on the primary.
PostgreSQL 16 already has sensible replication defaults (wal_level = replica, max_wal_senders = 10, max_replication_slots = 10), so the only mandatory change is making PostgreSQL listen on an address the standby can reach. Open the main configuration file:
sudo nano /etc/postgresql/16/main/postgresql.conf
Find listen_addresses, uncomment it and set it to localhost plus the primary's private IP:
listen_addresses = 'localhost,10.0.0.10'
While the file is open, confirm the replication settings. You do not need to change them, but make sure nobody has lowered them:
wal_level = replica
max_wal_senders = 10
max_replication_slots = 10
hot_standby = on
Save and close the file.
Creating the replication role
Create a dedicated role that can only replicate, not log in to databases. The \password command prompts for the password so it never ends up in your shell history:
sudo -u postgres psql
CREATE ROLE replicator WITH REPLICATION LOGIN;
\password replicator
\q
Choose a strong password and keep it at hand; you will use it on the standby.
Allowing the standby to connect
Replication connections are matched against the special replication database name in pg_hba.conf. Open the file:
sudo nano /etc/postgresql/16/main/pg_hba.conf
Add this line at the end, restricting access to the standby's IP:
host replication replicator 10.0.0.11/32 scram-sha-256
Restart PostgreSQL to apply listen_addresses (a reload is not enough for that setting):
sudo systemctl restart postgresql
Confirm PostgreSQL now listens on the private IP:
sudo ss -ltnp | grep 5432
LISTEN 0 200 10.0.0.10:5432 0.0.0.0:* users:(("postgres",pid=4121,fd=7))
LISTEN 0 200 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=4121,fd=6))
Opening the firewall
If UFW is active, allow port 5432 only from the standby:
sudo ufw allow from 10.0.0.11 to any port 5432 proto tcp
sudo ufw status
Step 3 - Preparing the standby
The following steps run on the standby.
A standby must start from an exact copy of the primary's data directory, so the empty cluster created by the package has to go. Stop PostgreSQL and remove its data directory:
sudo systemctl stop postgresql
sudo rm -rf /var/lib/postgresql/16/main
WarningRun the
rmcommand only on the standby. It deletes every database in that cluster.
Store the replication password in a .pgpass file owned by the postgres user, so neither pg_basebackup nor the running standby needs the password in a config file:
sudo -u postgres bash -c 'echo "10.0.0.10:5432:replication:replicator:your_strong_password" > ~/.pgpass && chmod 600 ~/.pgpass'
Replace your_strong_password with the password you set in Step 2. Test the connection before copying anything:
sudo -u postgres psql "host=10.0.0.10 user=replicator dbname=replication replication=true" -c "IDENTIFY_SYSTEM;"
systemid | timeline | xlogpos | dbname
---------------------+----------+-----------+--------
7412345678901234567 | 1 | 0/3000148 |
(1 row)
If this fails, fix connectivity, pg_hba.conf or the firewall before continuing (see Troubleshooting).
Step 4 - Cloning the primary with pg_basebackup
pg_basebackup copies the primary's data directory over a replication connection. The flags used here do the rest of the standby setup for you:
-X streamstreams the WAL generated during the copy, so the backup is consistent.-C -S standby1creates a physical replication slot namedstandby1on the primary. The slot makes the primary keep WAL until this standby has received it, so the standby can never fall too far behind to recover.-Rwritesstandby.signaland theprimary_conninfoconnection string into the data directory, which turns the copy into a standby.
Run it as the postgres user:
sudo -u postgres pg_basebackup -h 10.0.0.10 -U replicator \
-D /var/lib/postgresql/16/main \
-X stream -C -S standby1 -R -P
30832/30832 kB (100%), 1/1 tablespace
Check that the standby files were created:
sudo ls /var/lib/postgresql/16/main/standby.signal
sudo cat /var/lib/postgresql/16/main/postgresql.auto.conf
/var/lib/postgresql/16/main/standby.signal
# Do not edit this file manually!
# It will be overwritten by the ALTER SYSTEM command.
primary_conninfo = 'user=replicator passfile=''/var/lib/postgresql/.pgpass'' channel_binding=prefer host=10.0.0.10 port=5432 sslmode=prefer ...'
primary_slot_name = 'standby1'
Naming the standby
The primary identifies each standby by its application_name, which defaults to the standby's cluster_name. Ubuntu sets that to 16/main on every server, so give the standby a meaningful name. Open its configuration:
sudo nano /etc/postgresql/16/main/postgresql.conf
Set:
cluster_name = 'standby1'
This makes monitoring easier and is required if you later enable synchronous replication.
Step 5 - Starting the standby and verifying replication
Start PostgreSQL on the standby:
sudo systemctl start postgresql
Look at the log to confirm it connected to the primary:
sudo tail -n 5 /var/log/postgresql/postgresql-16-main.log
LOG: entering standby mode
LOG: redo starts at 0/4000028
LOG: consistent recovery state reached at 0/4000100
LOG: database system is ready to accept read-only connections
LOG: started streaming WAL from primary at 0/5000000 on timeline 1
On the primary, pg_stat_replication shows one row per connected standby:
sudo -u postgres psql -x -c "SELECT application_name, client_addr, state, sync_state, replay_lag FROM pg_stat_replication;"
-[ RECORD 1 ]----+----------
application_name | standby1
client_addr | 10.0.0.11
state | streaming
sync_state | async
replay_lag | 00:00:00.000912
state = streaming means the standby is receiving WAL in real time. On the standby, confirm it is in recovery mode:
sudo -u postgres psql -c "SELECT pg_is_in_recovery();"
pg_is_in_recovery
-------------------
t
(1 row)
Step 6 - Testing that data replicates
Create a test database and table on the primary:
sudo -u postgres psql -c "CREATE DATABASE repl_test;"
sudo -u postgres psql -d repl_test -c "CREATE TABLE t (id serial PRIMARY KEY, created_at timestamptz DEFAULT now()); INSERT INTO t DEFAULT VALUES;"
Read it from the standby:
sudo -u postgres psql -d repl_test -c "SELECT * FROM t;"
id | created_at
----+-------------------------------
1 | 2026-09-25 10:14:03.512391+00
(1 row)
Writes on the standby are rejected, which confirms it is read-only:
sudo -u postgres psql -d repl_test -c "INSERT INTO t DEFAULT VALUES;"
ERROR: cannot execute INSERT in a read-only transaction
Drop the test database on the primary when you are done; the drop replicates too:
sudo -u postgres psql -c "DROP DATABASE repl_test;"
Step 7 - Monitoring replication lag and slots
Two queries on the primary cover most day-to-day monitoring. The first shows how many bytes of WAL each standby still has to replay:
sudo -u postgres psql -c "SELECT application_name, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS replay_lag_bytes, replay_lag FROM pg_stat_replication;"
application_name | replay_lag_bytes | replay_lag
------------------+------------------+-----------------
standby1 | 0 bytes | 00:00:00.000874
The second shows how much WAL each replication slot is holding back. A slot whose standby is gone keeps WAL forever and can fill the primary's disk, so watch this value:
sudo -u postgres psql -c "SELECT slot_name, active, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal FROM pg_replication_slots;"
slot_name | active | retained_wal
-----------+--------+--------------
standby1 | t | 56 bytes
To put an upper bound on that risk, set max_slot_wal_keep_size in /etc/postgresql/16/main/postgresql.conf on the primary (for example max_slot_wal_keep_size = 20GB) and reload with sudo systemctl reload postgresql. If a standby falls behind by more than that, its slot is invalidated and the standby must be rebuilt with pg_basebackup, but the primary stays up.
Feed these two queries into your existing monitoring system (Prometheus postgres_exporter, Zabbix, or similar) and alert when active is false or the lag keeps growing.
Step 8 - Promoting the standby (manual failover)
If the primary fails, promote the standby so it accepts writes. Run this on the standby:
sudo -u postgres psql -c "SELECT pg_promote();"
pg_promote
------------
t
(1 row)
Verify it left recovery mode:
sudo -u postgres psql -c "SELECT pg_is_in_recovery();"
pg_is_in_recovery
-------------------
f
(1 row)
Then point your applications to the new primary. Two points matter after a failover:
- Never let the old primary come back as a writable server. If both accept writes, the data diverges. Keep it stopped until you rebuild it.
- Rebuild the old primary as a standby of the new one by repeating Steps 3 to 5 with the roles reversed (update
pg_hba.conf, the firewall and.pgpassaccordingly).pg_rewindcan speed this up for large databases, but it requireswal_log_hints = onor data checksums to have been enabled beforehand.
For automatic failover, use a cluster manager such as Patroni instead of scripting promotion yourself.
Optional - Enabling synchronous replication
With asynchronous replication, a transaction committed on the primary can be lost if the primary crashes before the standby receives it. Synchronous replication makes the primary wait for the standby to confirm each commit, at the cost of higher write latency and of writes blocking if the standby is down.
On the primary, edit /etc/postgresql/16/main/postgresql.conf:
synchronous_standby_names = 'FIRST 1 (standby1)'
synchronous_commit = on
Reload PostgreSQL:
sudo systemctl reload postgresql
Check that the standby is now synchronous:
sudo -u postgres psql -c "SELECT application_name, sync_state FROM pg_stat_replication;"
application_name | sync_state
------------------+------------
standby1 | sync
ImportantWith a single synchronous standby, every write on the primary waits if that standby is unavailable. Use at least two standbys with
ANY 1 (standby1, standby2)if you need synchronous durability without that single point of failure.
Troubleshooting
no pg_hba.conf entry for replication connection: the pg_hba.conf line on the primary is missing, uses the wrong IP, or was added without a reload. Check it and run sudo systemctl reload postgresql.
connection refused or timeout from the standby: PostgreSQL is not listening on the private IP (check listen_addresses and restart) or UFW blocks the port (sudo ufw status).
password authentication failed for user "replicator": the password in ~postgres/.pgpass on the standby does not match, or the file is not 600. PostgreSQL ignores a .pgpass with looser permissions.
requested WAL segment ... has already been removed: the standby fell behind and the WAL it needs is gone, usually because no slot was used or the slot was invalidated by max_slot_wal_keep_size. Rebuild the standby from Step 3.
Standby log shows database system identifier differs: the standby's data directory did not come from this primary. Remove it and run pg_basebackup again.
Conclusion
You now have a PostgreSQL 16 primary streaming WAL to a read-only hot standby through a replication slot, with queries to monitor lag and retained WAL and a tested promotion procedure. The standby can already take read-only traffic such as reports and analytics.
As next steps, consider adding a second standby for synchronous durability, managing failover automatically with Patroni, and encrypting replication traffic with TLS (ssl = on on the primary and hostssl entries in pg_hba.conf) if it crosses an untrusted network.
