Read Replicas
A read replica is a hot-standby Postgres that streams WAL from the primary and serves read-only queries. Pointing read traffic at it offloads the primary and adds read capacity — the Neon-style read/write split. Writes always go to the primary.
Off by default — opt-in
Read replicas are disabled by default. A plain docker compose up starts
no replica and the stack is byte-for-byte unaffected. A control plane with no
replica configured behaves exactly as before: every read and write hits the
primary. You opt in explicitly, in two small steps (below).
How it works
- The replica is a physical streaming standby — a
pg_basebackupcopy of the primary that continuously applies the primary’s WAL. It contains every project’splatform_<id>database (the project UUID with hyphens removed — 32 hex chars), identical to the primary (a few moments behind). - It runs read-only (
hot_standby). Any write against it is rejected with acannot execute … in a read-only transactionerror — correctness is preserved by construction. - The replica uses its own data volume — it is never sharing the primary’s.
- When
ANVILBASE_READ_REPLICA_HOSTis set, the control plane routes read-only RESTGET/HEADrequests to a per-project pool against the replica and surfaces a read-only connection string. Mutating REST methods (POST/PATCH/PUT/DELETE), RPC, andexec_sqlalways use the primary.
Enable it (Docker Compose)
The replica service lives behind the read-replica compose profile (off by
default).
1. Start the replica
docker compose --profile read-replica up -d postgres-replicaOn first start (empty data dir) the replica’s entrypoint
(docker/postgres/replica-entrypoint.sh) runs pg_basebackup from the primary
using the replicator role, writes standby.signal + primary_conninfo, then
starts as a hot standby that streams from the primary.
2. Point the control plane at it
Set the replica host in .env and restart the control plane:
ANVILBASE_READ_REPLICA_HOST=postgres-replica# ANVILBASE_READ_REPLICA_PORT=5432 # optional; defaults to 5432docker compose up -d control-planeWith the host set, read-only REST traffic routes to the replica and the
pooler endpoint starts returning a read_only
string. Leave ANVILBASE_READ_REPLICA_HOST empty to keep everything on the
primary (the default).
The primary side (replication enablement)
The primary needs three additive, non-disruptive things — all of which a fresh
primary (or a rebuilt image) gets automatically from init-anvilbase.owned.sh:
- A
replicatorrole (REPLICATION LOGIN). Its password seeds fromANVILBASE_REPLICATION_PASSWORD(falling back toANVILBASE_DB_PASSWORD). - A
pg_hba.confrule allowingreplicationforreplicator(scram-sha-256— the standby must present the password). wal_level = replica(already required by PITR / wal-g) andmax_wal_senders = 10(the PG 15 default — one replica uses a single sender).
These are inert until a replica connects, so a primary with no replica is completely unaffected by their presence.
Adding the replicator role to an already-running primary (no restart)
init-anvilbase.owned.sh only runs on a fresh data dir. For an existing
primary you add the role + pg_hba rule live, with no restart:
# 1. Create the replication role (use a strong password).docker exec -i anvilbase-postgres psql -U supabase_admin -d postgres <<'SQL'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'replicator') THEN CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'CHANGE_ME'; END IF;END $$;SQL
# 2. Append the pg_hba replication rule and reload (no restart).# IMPORTANT: write to the ACTIVE hba_file — the file the running server reads.# AnvilBase's image uses the standard layout, so its hba_file is# $PGDATA/pg_hba.conf. `SHOW hba_file` resolves the active file regardless.docker exec -i anvilbase-postgres bash -c ' HBA="$(psql -X -tA -U supabase_admin -d postgres -c "SHOW hba_file;" | tr -d "[:space:]")" if [ -n "$HBA" ] && ! grep -Eq "^[[:space:]]*host[[:space:]]+replication[[:space:]]+replicator[[:space:]]" "$HBA"; then { echo "host replication replicator 0.0.0.0/0 scram-sha-256" echo "host replication replicator ::/0 scram-sha-256" } >> "$HBA" fi'docker exec -i anvilbase-postgres psql -U supabase_admin -d postgres \ -c "SELECT pg_reload_conf();"wal_level=replica and max_wal_senders=10 are already in effect on any
AnvilBase deployment (PITR requires replica, and 10 is the PG 15 default), so
no change — and no restart — is needed for those.
Then set REPL_PASSWORD on the postgres-replica service to the same value and
start it.
Read-only connection string
The pooler endpoint surfaces a read-only string when a replica is configured:
GET /api/v1/projects/{id}/pooler{ "project_id": "…", "enabled": true, "mode": "transaction", "pooled": "postgres://pooler_<pid>.<id>:…@<pooler-host>/platform_<id>?sslmode=require", "direct": "postgres://pooler_<pid>:…@<primary-host>:5432/platform_<id>?sslmode=require", "read_only": "postgres://pooler_<pid>:…@postgres-replica:5432/platform_<id>?sslmode=require"}read_onlyauthenticates as the same per-projectpooler_<pid>role (RLS is enforced) but dials the replica host. Use it for read-heavy queries.- It is omitted entirely when no replica is configured — no string is fabricated.
- Writes must use
directorpooled. The replica rejects writes.
Kubernetes (Helm)
The chart ships an optional read-replica StatefulSet, disabled by default:
postgres: readReplica: enabled: false # set true to add a replica replicas: 1 replicationUser: replicator replicationPassword: "" # empty => reuse postgres.anvilbaseDbPassword storage: size: 10GiWhen postgres.readReplica.enabled=true, the chart renders the replica
StatefulSet + Service (with its own PVC) and wires ANVILBASE_READ_REPLICA_HOST
into the control plane automatically. With it false (the default) zero
replica objects are rendered and the control plane never references a replica.
Notes & limits
- Replication is asynchronous — the replica is a few moments behind the
primary. Read-your-own-writes consistency is not guaranteed against the
replica; for that, read from the primary (
direct/pooled). - This is physical (streaming) replication, not logical — the replica is a full copy of the cluster, not a per-table subscription.
- One replica uses a single WAL sender; the primary’s
max_wal_senders=10leaves ample headroom for additional standbys.