1. Managed Databases vs. Self-Hosting
Week 15's CloudNativePG Operator makes self-hosting Postgres in Kubernetes genuinely viable — replication, failover, and backups are all handled. That doesn't make it the default right answer. RDS (or Aurora) hands the same operational concerns to AWS entirely: automated patching, automated failover, and automated backups, with none of it running as workloads your cluster has to schedule, monitor, or upgrade.
The honest trade-off: a managed database costs more per unit of compute than the equivalent self-hosted instance, and it's less flexible — a specific Postgres extension your team needs might not be supported on RDS. In exchange, a small team gets a production-grade database without needing deep, ongoing database administration expertise on staff. A larger platform team with that expertise, or a workload with unusual requirements a managed service can't meet, is where self-hosting with an Operator starts to make more sense.
resource "aws_db_instance" "orders" {
identifier = "orders-db"
engine = "postgres"
engine_version = "16.3"
instance_class = "db.t3.medium"
allocated_storage = 50
storage_type = "gp3"
multi_az = true # synchronous standby in a second AZ
backup_retention_period = 14 # days -- required for PITR (Section 2)
deletion_protection = true
skip_final_snapshot = false
}
multi_az = true is the managed equivalent of what Week 15's Operator
built manually — a standby replica AWS keeps synchronously up to date and
automatically promotes if the primary fails, with the entire failover mechanism as
one boolean instead of CRD configuration you own and maintain.
Week 15 proved self-hosting is technically achievable with an Operator — that's a different question from whether it's the right operational bet for a given team's size and priorities. Being able to articulate both the cost and the operational trade-off, not just "Kubernetes can do it," is what a real infrastructure decision actually requires.
2. Backups, Point-in-Time Recovery & Tested Restores
A daily snapshot answers "can I get back to yesterday." Point-in-time
recovery (PITR) answers a sharper question: "can I get back to 2:47pm today,
one minute before someone ran a bad UPDATE with no WHERE
clause." RDS achieves this by continuously archiving transaction logs between
snapshots, so a restore can replay to any specific second within the retention
window, not just to the last snapshot boundary.
aws rds restore-db-instance-to-point-in-time \
--source-db-instance-identifier orders-db \
--target-db-instance-identifier orders-db-restored \
--restore-time "2026-08-10T14:47:00Z"
# always restores to a NEW instance -- never overwrites the original
That restore always creates a separate new instance rather than overwriting the original — deliberately, so a bad restore doesn't compound a bad situation by destroying the only other copy of the data. The single most important habit this section teaches isn't configuring backups at all, though — it's this:
Backup jobs "succeeding" for months tells you the backup process ran without erroring — it tells you nothing about whether the resulting snapshot is actually restorable, or whether your team knows the exact restore procedure under pressure. Schedule an actual quarterly restore drill, exactly like Week 22's game days, so the first real restore isn't happening during a live incident with everyone learning the command for the first time.
3. Read Replicas, Connection Pooling & Zero-Downtime Migrations
A single database instance eventually becomes the bottleneck under enough read traffic. A read replica — an asynchronously-updated copy AWS maintains automatically — lets read-heavy queries (a dashboard, a reporting job) run against the replica instead of competing with writes on the primary. Asynchronous replication means a small, usually sub-second lag is a normal trade-off: a replica is the wrong place to read data that was just written in the same request.
resource "aws_db_instance" "orders_replica" {
identifier = "orders-db-replica"
replicate_source_db = aws_db_instance.orders.identifier
instance_class = "db.t3.medium"
}
At higher connection counts, RDS Proxy sits between the app and the database and pools connections — the same problem Week 18's callout about Lambda connections flagged, generalized: a fleet of Pods or Lambda invocations opening connections directly can exhaust the database's connection limit long before it runs out of CPU or memory.
Schema changes are the other place state quietly breaks a deploy. Adding a
NOT NULL column to a large table with a default value can lock it for
the entire rewrite in some databases — a multi-second lock is an outage for every
request hitting that table. The standard safe pattern is an expand-contract
migration, spread across separate deploys instead of one:
# Deploy 1 (expand): add the column as NULLABLE, no lock, app doesn't use it yet
ALTER TABLE orders ADD COLUMN status TEXT;
# Deploy 2: app writes to BOTH the old and new column; backfill existing rows
UPDATE orders SET status = 'unknown' WHERE status IS NULL;
# Deploy 3 (contract): once backfilled and app fully cut over, enforce it
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
No single step in that sequence requires the old and new application code to
disagree about the schema at any point — which is exactly what makes it compatible
with Week 15's rolling updates, where old and new Pods briefly run side by side during
a deploy. A single-step migration that adds a NOT NULL column the
application immediately depends on breaks the moment an old Pod, still running mid-rollout, tries to insert a row without it.
This single rule generates the entire expand-contract pattern above: since a rolling update always has a window with both versions live, any migration that would break if only one version's assumptions held is unsafe by definition — not just for a large table, but as a habit worth applying to every schema change regardless of table size.
4. Hands-on Exercise
Stand up RDS with PITR, add a replica, and run a real expand-contract migration
Put every practice from this week to work against a real (free-tier) RDS instance.
Requirements:
- Provision an RDS Postgres instance via Terraform with a 7-day backup retention period enabled for PITR.
- Insert some test data, note the timestamp, then make a destructive change (delete or corrupt a row) and perform a real point-in-time restore to just before that change — to a new instance, per the safe pattern in Section 2.
- Add a read replica, and confirm from the application side that a write to the primary is visible on the replica after a brief delay, demonstrating the asynchronous lag.
- Run a three-deploy expand-contract migration end to end on a test table: add a nullable column, backfill and dual-write, then enforce
NOT NULL— with your application still running (and working) throughout all three steps. - Write a short note on which of RDS vs. self-hosting with the Week 15 Operator you'd choose for this specific workload, and why.
RDS free-tier eligibility (db.t3.micro, 20GB) covers this entire exercise for 12 months on a new AWS account — confirm your instance class qualifies before applying, and remember to terraform destroy the restored instance from step 2 once you've confirmed it, since a PITR restore is a second billable instance running alongside the original.
5. Knowledge Check
Three quick questions. Expand each to check your answer.
Q1
Why does a point-in-time restore always create a new instance instead of restoring in place?
Why does a point-in-time restore always create a new instance instead of restoring in place?
Restoring in place would destroy the original instance's current state as part of the restore, so if the restore turns out to be wrong — the wrong timestamp, an unexpected issue — there's no way back and the situation has gotten strictly worse. Restoring to a new instance keeps the original untouched as a fallback until the restored copy is verified correct.
Q2
Why is it unsafe to read data from a read replica immediately after writing it in the same request?
Why is it unsafe to read data from a read replica immediately after writing it in the same request?
Read replicas update asynchronously, with a small (usually sub-second, but not guaranteed) replication lag behind the primary. A write followed immediately by a read against the replica can return stale data that doesn't yet reflect the write, which is why reads that must be immediately consistent with a just-completed write need to go to the primary, not a replica.
Q3
What single rule explains why an expand-contract migration needs three separate deploys instead of one?
What single rule explains why an expand-contract migration needs three separate deploys instead of one?
A rolling update always has a window where old and new application Pods run simultaneously against the same database. Any migration has to keep working correctly for both versions during that window — a single-step migration that immediately requires the new schema shape breaks the old Pods still running mid-rollout, which is exactly what splitting the change into expand, backfill, and contract phases avoids.