Week 24: Databases & Stateful Workloads in the Cloud

Every service deployed since Week 11 has quietly assumed a database exists somewhere and just works. This week is about that assumption: whether to run it managed or self-hosted, how backups actually get tested (not just taken), how to scale reads without touching writes, and how to change a running schema without taking the app down. This is the last module before the capstone, and its subject — the state everything else in this course ultimately depends on — is exactly what the capstone in Weeks 25–26 has to get right.

Module 21 of 22 Week 24 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Decide between a managed database and self-hosting in Kubernetes for a given workload
  • Configure backups with point-in-time recovery and actually test a restore
  • Add a read replica and run a zero-downtime schema migration

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.

terraform — a Multi-AZ RDS Postgres instance
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.

"We could self-host it" isn't the same question as "we should"

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.

terminal — a point-in-time restore
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:

A backup nobody has ever restored from is a hypothesis, not a backup

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.

terraform — a read replica
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:

expand-contract: adding a NOT NULL column safely, across 3 deploys
# 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.

Every migration should work with both the old and new app code running simultaneously

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

Hands-on

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:

  1. Provision an RDS Postgres instance via Terraform with a 7-day backup retention period enabled for PITR.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Hint

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?

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?

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?

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.