What You'll Need
- Hetzner VPS or Contabo VPS for hosting Docker containers
- DigitalOcean as an alternative cloud provider
- Namecheap if you need a domain for database proxies
- Docker Engine version 20.10.0 or higher
- Docker Compose v2 or higher
Table of Contents
- Understanding PostgreSQL Streaming Replication
- Configuring the Primary PostgreSQL Instance
- Writing the Replica Bootstrapping Engine
- Building the Production Docker Compose Stack
- Validating Replication and Monitoring Delay
- Getting Started
Understanding PostgreSQL Streaming Replication
Scaling a relational database engine requires splitting write operations from read-heavy analytical or transactional queries. When your application scales past a single node, running heavy reads on your primary database degrades transactional write throughput. If you run data ingestion workers or build complex reporting engines, offloading SELECT queries to secondary databases becomes mandatory.
PostgreSQL handles data redundancy using streaming replication based on Write-Ahead Logging (WAL). Every modifying transaction (INSERT, UPDATE, DELETE) writes log records to disk before making changes to data files. In a primary and replica setup, the primary database streams these WAL records across TCP connections to one or more replica nodes. The replica continuously replays these incoming WAL records, keeping its state nearly identical to the primary.
Physical streaming replication yields byte-for-byte exact replicas of your primary storage layer. Replicas run in read-only mode, meaning they reject write queries while allowing full read accessibility. When integrated with worker environments, such as those described in How to Build Distributed Web Scraping Pipelines, streaming replication prevents bulk read queries from bottlenecking real-time write queues on your core primary database.
Using Docker containers to deploy a primary replica cluster simplifies configuration management, isolate storage drivers, and ensure consistent behavior across development and production servers.
Configuring the Primary PostgreSQL Instance
To establish streaming replication in Docker, the primary database node must execute custom initialization scripts that set up a replication user, reserve replication slots, and configure core WAL parameters.
We begin by establishing a clear file structure for our database cluster:
pg-cluster/
├── docker-compose.yml
├── primary/
│ ├── custom-primary.conf
│ └── init-primary.sh
└── replica/
└── docker-replica-entrypoint.sh
First, we define primary/custom-primary.conf. This file overrides default PostgreSQL settings to enable streaming output, set replication limits, and maintain transaction logs during heavy background processing.
listen_addresses = '*'
wal_level = replica
max_wal_senders = 10
max_replication_slots = 10
hot_standby = on
hot_standby_feedback = on
wal_keep_size = 512MB
max_standby_streaming_delay = 30s
Here is a breakdown of these essential parameters:
-
wal_level: Set toreplicato log required structural data for read-only streaming. -
max_wal_senders: Defines maximum concurrent streaming connection threads. -
max_replication_slots: Specifies maximum replication slots reserved on the primary node. Replication slots prevent the primary from deleting old WAL segments until replicas consume them. -
hot_standby_feedback: Sends query state back from replica to primary, preventing primary autovacuum operations from deleting tuples currently being queried on replicas. -
wal_keep_size: Guarantees a minimum log volume retainage on disk even if connections drop temporarily.
Next, create primary/init-primary.sh. PostgreSQL automatically executes any script placed inside /docker-entrypoint-initdb.d/ on initial database cluster creation.
#!/bin/bash
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD 'replicator_password';
SELECT * FROM pg_create_physical_replication_slot('replica_1_slot');
SELECT * FROM pg_create_physical_replication_slot('replica_2_slot');
EOSQL
echo "host replication replicator 0.0.0.0/0 scram-sha-256" >> "$PGDATA/pg_hba.conf"
This script generates a dedicated user named replicator holding explicit replication permissions. It then creates physical replication slots named replica_1_slot and replica_2_slot. Finally, it appends an authorization rule to pg_hba.conf allowing the replicator account access from any internal container network using scram-sha-256 password authentication.
💡 Fast-Track Your Project: Don't want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-DEVTO.
Writing the Replica Bootstrapping Engine
Read replicas must initialize from a clean physical copy of the primary database system files. We use the standard PostgreSQL utility pg_basebackup to clone the primary storage directory across the network.
When deploying through Docker, official PostgreSQL images auto-initialize a brand new PostgreSQL cluster inside /var/lib/postgresql/data if the directory is empty. We must intercept container startup to wipe default files, execute pg_basebackup, create standby signal flags, and boot PostgreSQL in recovery mode.
Create replica/docker-replica-entrypoint.sh:
#!/bin/bash
set -e
if [ -s "$PGDATA/PG_VERSION" ]; then
echo "Existing database data detected in $PGDATA. Skipping basebackup initialization."
else
echo "No existing data found. Starting basebackup sync process..."
until PGPASSWORD='replicator_password' psql -h postgres-primary -U replicator -d postgres -c '\q' 2>/dev/null; do
echo "Primary database service is unreachable. Waiting 2 seconds before retrying..."
sleep 2
done
echo "Primary engine detected. Wiping empty volume target..."
rm -rf "${PGDATA:?}"/*
echo "Executing pg_basebackup stream clone..."
PGPASSWORD='replicator_password' pg_basebackup \
-h postgres-primary \
-D "$PGDATA" \
-U replicator \
-v \
-P \
-X stream \
-S "${REPLICATION_SLOT_NAME}" \
-R
chmod 700 "$PGDATA"
echo "Basebackup clone finished successfully. Launching replica server process."
fi
exec docker-entrypoint.sh "$@"
Let's dissect the critical arguments assigned to pg_basebackup:
-
-h postgres-primary: Connects directly to the primary container service name using Docker DNS routing. -
-D "$PGDATA": Defines the local target directory where cluster files are extracted. -
-U replicator: Authenticates using our explicit replication service user. -
-X stream: Streams transaction log records concurrently as the backup runs to guarantee consistency. -
-S "${REPLICATION_SLOT_NAME}": Binds the replica container to its dedicated physical replication slot defined on the primary node. -
-R: Generates auto-configuration files (standby.signaland connection string attributes inpostgresql.auto.conf) instructing PostgreSQL to boot as a read-only secondary replica.
Ensure this initialization shell script is marked executable locally:
chmod +x replica/docker-replica-entrypoint.sh
chmod +x primary/init-primary.sh
Building the Production Docker Compose Stack
With initialization scripts complete, we assemble our infrastructure stack inside docker-compose.yml. We will deploy a master node (postgres-primary) along with two physical read replicas (postgres-replica-1 and postgres-replica-2).
When deploying on host infrastructure like a Hetzner VPS or a DigitalOcean droplet, you can pin external host ports or route query traffic through local socket adapters.
Here is the entire container orchestration blueprint:
version: '3.8'
networks:
pg-network:
driver: bridge
volumes:
pg_primary_data:
pg_replica_1_data:
pg_replica_2_data:
services:
postgres-primary:
image: postgres:16-alpine
container_name: postgres-primary
restart: always
networks:
- pg-network
ports:
- "5432:5432"
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: primary_admin_password
POSTGRES_DB: production_db
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- pg_primary_data:/var/lib/postgresql/data
- ./primary/init-primary.sh:/docker-entrypoint-initdb.d/init-primary.sh
- ./primary/custom-primary.conf:/etc/postgresql/postgresql.conf
command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d production_db"]
interval: 5s
timeout: 5s
retries: 5
postgres-replica-1:
image: postgres:16-alpine
container_name: postgres-replica-1
restart: always
networks:
- pg-network
ports:
- "5433:5432"
depends_on:
postgres-primary:
condition: service_healthy
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: primary_admin_password
POSTGRES_DB: production_db
PGDATA: /var/lib/postgresql/data/pgdata
REPLICATION_SLOT_NAME: replica_1_slot
volumes:
- pg_replica_1_data:/var/lib/postgresql/data
- ./replica/docker-replica-entrypoint.sh:/usr/local/bin/docker-replica-entrypoint.sh
entrypoint: ["/usr/local/bin/docker-replica-entrypoint.sh"]
command: ["postgres"]
postgres-replica-2:
image: postgres:16-alpine
container_name: postgres-replica-2
restart: always
networks:
- pg-network
ports:
- "5434:5432"
depends_on:
postgres-primary:
condition: service_healthy
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: primary_admin_password
POSTGRES_DB: production_db
PGDATA: /var/lib/postgresql/data/pgdata
REPLICATION_SLOT_NAME: replica_2_slot
volumes:
- pg_replica_2_data:/var/lib/postgresql/data
- ./replica/docker-replica-entrypoint.sh:/usr/local/bin/docker-replica-entrypoint.sh
entrypoint: ["/usr/local/bin/docker-replica-entrypoint.sh"]
command: ["postgres"]
This compose file structures our environment cleanly:
-
postgres-primarymaps host port 5432 to container port 5432. It mounts the custom initialization script and parameter file, applying health checks before dependent services boot. -
postgres-replica-1exposes host port 5433 to container port 5432. It assigns environment parameterREPLICATION_SLOT_NAME=replica_1_slot. -
postgres-replica-2exposes host port 5434 to container port 5432. It assigns environment parameterREPLICATION_SLOT_NAME=replica_2_slot.
If you manage container maintenance across continuous integration environments, read our guide on How to Deploy Watchtower for Docker Containers to securely handle image updates without causing unannounced database state divergence.
Additionally, if your read replicas are exposed to remote services across public infrastructure, consult our guide on Configuring Nginx SSL Certificates for Custom Subdomains to set up secure reverse proxies using TLS termination.
Now, initialize the entire stack:
docker compose up -d
Monitor container initialization using docker logs:
docker compose logs -f
Validating Replication and Monitoring Delay
Once all three containers report running states, you must verify active replication streaming and assert write lock enforcement on secondary nodes.
1. Verify Active Replication on Primary
Connect to postgres-primary and query the system table pg_stat_replication:
docker exec -it postgres-primary psql -U admin -d production_db -c "SELECT client_addr, application_name, state, sync_state, replay_lag FROM pg_stat_replication;"
Your terminal outputs active replication records matching the generated slots:
client_addr | application_name | state | sync_state | replay_lag
-------------+------------------+-----------+------------+------------
172.22.0.3 | walreceiver | streaming | async | 00:00:00
172.22.0.4 | walreceiver | streaming | async | 00:00:00
(2 rows)
2. Confirm Standby Mode on Replicas
Connect to postgres-replica-1 and query standby state status:
docker exec -it postgres-replica-1 psql -U admin -d production_db -c "SELECT pg_is_in_recovery();"
The terminal returns t (true), proving the node is executing in recovery standby mode.
pg_is_in_recovery
-------------------
t
(1 row)
3. Test Read-Only Constraints and Write Syncing
Create a test table and populate entries on postgres-primary:
docker exec -it postgres-primary psql -U admin -d production_db -c "CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(50));"
docker exec -it postgres-primary psql -U admin -d production_db -c "INSERT INTO users (name) VALUES ('Alice'), ('Bob');"
Now query postgres-replica-1 on host port 5433 to confirm automatic read synchronization:
docker exec -it postgres-replica-1 psql -U admin -d production_db -c "SELECT * FROM users;"
Output:
id | name
----+-------
1 | Alice
2 | Bob
(2 rows)
Finally, verify that write queries sent directly to postgres-replica-1 are blocked by PostgreSQL:
docker exec -it postgres-replica-1 psql -U admin -d production_db -c "INSERT INTO users (name) VALUES ('Charlie');"
PostgreSQL aborts the transaction instantly with an explicit read-only state error:
ERROR: cannot execute INSERT in a read-only transaction
4. Measuring Replication Lag
Under heavy transactional loads, replicas may lag behind the primary node. You can measure byte lag directly on secondary instances by comparing transaction log positions:
docker exec -it postgres-replica-1 psql -U admin -d production_db -c "SELECT pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(), pg_last_xact_replay_timestamp();"
This returns current WAL record positions and exact timestamps of the last applied transaction, letting you build metrics dashboards to ensure data staleness stays within acceptable SLAs.
Getting Started
Ready to deploy read-scalable database clusters? Spin up high-performance cloud servers to host your containerized PostgreSQL databases using these recommended platforms:
- Choose a high-performance compute host like Hetzner VPS or Contabo VPS.
- Deploy scalable cloud infrastructure on DigitalOcean.
- Secure domain names for database proxy layers using Namecheap.
Outsource Your Automation
Don't have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr, mention SYS3-DEVTO for priority. Or DM at chasebot.online.
Originally published on Automation Insider.
Top comments (0)