RDS Read Replicas vs Self-Managed PostgreSQL Replicas

Problem statement: You are choosing between Amazon RDS read replicas and PostgreSQL streaming replicas you run yourself on EC2 β€” and the two look interchangeable on a slide but diverge sharply the moment you need a specific postgresql.conf parameter, a replication slot you control, or lag telemetry finer than one data point per minute.


Symptom Identification

These are the concrete signals that push a team from one model to the other. If you are on RDS and hitting these, self-managed starts to look attractive:

  • A required parameter (for example shared_preload_libraries needing an extension RDS does not package, or an aggressive max_standby_streaming_delay) returns β€œThis parameter is not modifiable” in the parameter group.
  • You need a physical replication slot you can inspect and drop for a downstream CDC pipeline, and the RDS engine version does not expose it.
  • Your incident review needs sent_lsn/replay_lsn byte deltas, but all you have is ReplicaLag at 60-second granularity.
  • You want a cascading replica (read replica of a read replica) with a custom apply delay that RDS’s cascade rules will not honor.

And the reverse β€” signals you are on self-managed and RDS would remove pain:

  • Replication slot bloat has filled the primary’s pg_wal volume more than once and paged the on-call.
  • Patching, base backups, and minor-version upgrades consume real engineer-days each quarter.
  • Your hand-rolled pg_ctl promote failover has a fencing gap that risks split-brain.

Root Cause Analysis

The two models are not the same product with different price tags β€” they draw the control boundary in a different place.

RDS read replicas are physical streaming replicas whose apply pipeline, slot lifecycle, WAL archive, and (with Multi-AZ) failover live inside AWS’s control plane. You configure them exclusively through DB parameter groups, and AWS marks a subset of GUCs non-modifiable to protect the managed contract. You get a documented create-db-instance-read-replica API, automated storage management, and ReplicaLag in CloudWatch β€” but no shell, no raw postgresql.conf, and historically limited replication-slot control that has expanded only gradually across engine versions.

Self-managed PostgreSQL replicas are ordinary standbys in hot_standby mode. Every knob is yours: primary_conninfo, primary_slot_name, hot_standby_feedback, recovery_min_apply_delay, apply parallelism, and the full promotion toolchain. In exchange you own base backups, WAL archiving, the monitoring exporter, patching, and the failover automation and its fencing logic.

The decision reduces to one question: does your workload need a control-plane knob that RDS hides, and is that need worth taking on the operational surface RDS absorbs? This is the specific PostgreSQL instance of the broader managed vs self-managed read replica trade-off, and it rests on the physical vs logical replication model you are running.


Architecture: Where the Control Boundary Sits

RDS versus self-managed PostgreSQL replica control boundary The RDS path shows a source instance streaming to a read replica whose slot, WAL archive and failover are inside the AWS control plane, with only a parameter group and CloudWatch ReplicaLag exposed. The self-managed path shows a primary streaming over a named slot to a standby whose postgresql.conf, exporter and promote script are all directly operated. RDS Source DB appdb-primary managed WAL Read replica no shell control plane: slot · WAL archive · failover exposed: parameter group only CloudWatch ReplicaLag · 60 s Self-managed Primary your EC2 host named slot Standby hot_standby=on postgresql.conf postgres_exporter promote script RDS: convenience, bounded knobs — Self-managed: every knob, every page

Step-by-Step Resolution

Use this procedure whether you are moving off RDS to self-managed or the reverse. The steps are ordered to surface blockers before you commit.

Step 1: Audit required parameters and extensions

List every GUC and extension your workload depends on, then check each against the RDS modifiable-parameter list for your engine version.

sql
-- On the current primary: dump the non-default GUCs you actually rely on
SELECT name, setting, source
FROM pg_settings
WHERE source NOT IN ('default', 'override')
ORDER BY name;

-- And the extensions in use
SELECT extname, extversion FROM pg_extension ORDER BY extname;

Inline verification: Every row in the first result must map to either a modifiable RDS parameter or an RDS-supported extension. Any that do not are hard blockers for the RDS direction.

Step 2: Map the replication-slot requirement

If you need explicit slot control β€” for CDC, a downstream logical subscriber, or slot-based lag monitoring β€” confirm what the target platform exposes.

sql
-- Self-managed primary: create and inspect a physical slot
SELECT pg_create_physical_replication_slot('replica01_slot');
SELECT slot_name, slot_type, active, restart_lsn FROM pg_replication_slots;

On RDS, slot capabilities depend on engine version and the rds.logical_replication parameter; verify before assuming parity.

Inline verification: On self-managed, pg_replication_slots shows your slot with active = t once the standby connects. On RDS, confirm slot creation succeeds without a permissions error.

Step 3: Reproduce lag visibility on the target

bash
# RDS: read ReplicaLag directly
aws cloudwatch get-metric-statistics \
  --namespace AWS/RDS --metric-name ReplicaLag \
  --dimensions Name=DBInstanceIdentifier,Value=appdb-replica-1 \
  --start-time "$(date -u -d '10 min ago' +%FT%TZ)" \
  --end-time "$(date -u +%FT%TZ)" \
  --period 60 --statistics Average
sql
-- Self-managed: byte + wall-clock lag, arbitrary granularity
SELECT application_name,
       pg_wal_lsn_diff(sent_lsn, replay_lsn) AS bytes_behind,
       EXTRACT(EPOCH FROM replay_lag) AS replay_lag_s
FROM pg_stat_replication;

Inline verification: RDS returns one datapoint per 60 s; the self-managed query returns per-scrape byte and second deltas. Confirm your alerting can consume whichever you land on.

Step 4: Establish the failover contract

On RDS, promotion is aws rds promote-read-replica or automatic during a Multi-AZ event. On self-managed, wire Patroni or repmgr with fencing so a partitioned old primary cannot accept writes.

bash
# RDS manual promotion (breaks replication, makes it standalone-writable)
aws rds promote-read-replica --db-instance-identifier appdb-replica-1

Inline verification: After promotion, SELECT pg_is_in_recovery(); must return f on the promoted node, and your read pool must no longer target it as a replica.

Step 5: Cut over reads behind a pool

Point your read-only pool at the new endpoint and drain the old one. Never re-point application code directly at replica hostnames β€” route through the pool so cutover is a config change, not a deploy. See configuring PgBouncer for read-only connection pools.

Inline verification: Through the pool, SELECT pg_is_in_recovery(); returns t and a test INSERT is rejected with cannot execute INSERT in a read-only transaction.


Configuration Snippet

Equivalent read-only enforcement on each platform:

ini
# Self-managed: postgresql.conf on the standby
hot_standby = on
default_transaction_read_only = on
hot_standby_feedback = on
max_standby_streaming_delay = 30s
bash
# RDS: the same intent via parameter group (no file access)
aws rds modify-db-parameter-group \
  --db-parameter-group-name appdb-replica-readonly \
  --parameters \
    "ParameterName=default_transaction_read_only,ParameterValue=1,ApplyMethod=immediate" \
    "ParameterName=hot_standby_feedback,ParameterValue=1,ApplyMethod=immediate"

Verification and Rollback

Confirm the chosen platform is serving reads correctly:

sql
SELECT pg_is_in_recovery();            -- t on any replica
SHOW default_transaction_read_only;    -- on
SELECT pg_last_wal_replay_lsn();        -- advancing over time

Rollback (self-managed β†’ RDS abandoned mid-migration): re-point the pool back to the RDS reader endpoint; RDS replicas continued streaming throughout, so no data was lost.

Rollback (RDS β†’ self-managed abandoned): if the self-managed standby fell behind, drop its slot on the source (SELECT pg_drop_replication_slot('replica01_slot');) to stop WAL retention, then rebuild from a fresh base backup once the underlying blocker is resolved.


Edge Cases and Gotchas

RDS marks the parameter modifiable but ignores it on replicas

Some parameters are modifiable in the parameter group yet only take effect on the writer, or require a reboot the replica schedules on its own cadence. Always confirm the running value on the replica itself with SHOW <param>; after applying, rather than trusting the parameter-group state.

Self-managed slots silently retain WAL after a standby dies

A physical slot keeps restart_lsn pinned even when the standby is gone, and the primary retains all WAL since that point. Set max_slot_wal_keep_size so the primary invalidates the slot before pg_wal fills the disk β€” the single most common way self-managed replication takes down a primary.

Major-version upgrades diverge

RDS performs in-place major-version upgrades on its own schedule and can break replica compatibility mid-window; self-managed upgrades require you to rebuild or pg_upgrade the standby yourself. Neither is free β€” budget the upgrade path explicitly when you pick a platform, and cross-check it against your replication lag threshold SLAs.


FAQ

Can I create a logical replication slot on an RDS read replica?

Recent RDS for PostgreSQL versions expose logical replication and let you create logical slots on the primary with rds.logical_replication enabled, and newer versions allow logical slots on replicas too. Older engine versions did not give you slot control at all. Always confirm against your exact engine version, because this is one of the capabilities that has changed most across RDS releases.

How fine-grained is CloudWatch ReplicaLag compared to pg_stat_replication?

ReplicaLag is a single wall-clock-seconds value published at 60-second granularity. A self-managed setup scraping pg_stat_replication gives you sent, write, flush, and replay LSN deltas at whatever interval you scrape, typically 5 seconds, plus byte-level queue depth. RDS hides the byte deltas and the sub-minute detail.

Is self-managed always cheaper than RDS for replicas?

Not once you price in engineering time. Self-managed saves the RDS per-instance management premium, but you pay for base backups, monitoring, patching, and on-call. Self-managed wins on raw cost at large replica counts or when you already run a platform team; RDS wins for small teams where the premium is cheaper than the head count.


← Back to Managed vs Self-Managed Read Replicas