Skip to content

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_basebackup copy of the primary that continuously applies the primary’s WAL. It contains every project’s platform_<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 a cannot execute … in a read-only transaction error — correctness is preserved by construction.
  • The replica uses its own data volume — it is never sharing the primary’s.
  • When ANVILBASE_READ_REPLICA_HOST is set, the control plane routes read-only REST GET/HEAD requests to a per-project pool against the replica and surfaces a read-only connection string. Mutating REST methods (POST/PATCH/PUT/DELETE), RPC, and exec_sql always use the primary.

Enable it (Docker Compose)

The replica service lives behind the read-replica compose profile (off by default).

1. Start the replica

Terminal window
docker compose --profile read-replica up -d postgres-replica

On 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:

.env
ANVILBASE_READ_REPLICA_HOST=postgres-replica
# ANVILBASE_READ_REPLICA_PORT=5432 # optional; defaults to 5432
Terminal window
docker compose up -d control-plane

With 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:

  1. A replicator role (REPLICATION LOGIN). Its password seeds from ANVILBASE_REPLICATION_PASSWORD (falling back to ANVILBASE_DB_PASSWORD).
  2. A pg_hba.conf rule allowing replication for replicator (scram-sha-256 — the standby must present the password).
  3. wal_level = replica (already required by PITR / wal-g) and max_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:

Terminal window
# 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_only authenticates as the same per-project pooler_<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 direct or pooled. The replica rejects writes.

Kubernetes (Helm)

The chart ships an optional read-replica StatefulSet, disabled by default:

values.yaml
postgres:
readReplica:
enabled: false # set true to add a replica
replicas: 1
replicationUser: replicator
replicationPassword: "" # empty => reuse postgres.anvilbaseDbPassword
storage:
size: 10Gi

When 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=10 leaves ample headroom for additional standbys.