Build a Production Database Backup and Disaster Recovery System on AWS
Replicate encrypted RDS PostgreSQL backups to a second region with Terraform, rehearse point-in-time recovery, and auto-test restores every week.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project builds a backup and disaster recovery system for an encrypted Amazon RDS PostgreSQL database with Terraform. Automated backups are replicated to a second region, you rehearse point-in-time recovery by hand, and a scheduled CodeBuild job restores the replicated backup into the second region every week, runs validation queries, records how long the restore took and emails you if anything fails. You finish with measured recovery numbers and a runbook, not just a hope that backups work.
The problem this solves
Most teams find out their backups do not work during a real incident. The backup job ran, but nobody ever restored it. The copy exists, but in the wrong region, or encrypted with a key the recovery region cannot use. A scheduled task has been failing quietly for months.
Picture a payments company like Razorpay or Zerodha with a primary database in Mumbai. Two different disasters matter. A bad deployment deletes rows, which needs point-in-time recovery (PITR): restoring to the minute before the mistake. A regional outage needs cross-region recovery: bringing the data back in another region. This project covers both and proves each one works.
Two terms to keep straight. RPO (recovery point objective) is how much data you can afford to lose, measured as the age of the newest restorable point. RTO (recovery time objective) is how long recovery takes. You will measure both for your own setup instead of assuming a number.
What you will build
| Piece | What it does |
|---|---|
| Encrypted RDS PostgreSQL | The production-style primary database with 14 days of automated backups |
| Automated backup replication | Copies snapshots and transaction logs to Singapore, so PITR works there too |
| KMS keys in both regions | Encryption keys are regional, so each region needs its own |
| Replicated secret | The test job reads the database password from Singapore, not Mumbai |
| DR network | A pre-built VPC in Singapore, because you cannot create networking in the middle of an outage |
| CodeBuild restore test | Restores, validates, measures and deletes the test database every week |
| Alerts | Emails for backup failures, low storage, failed tests and tests that stop running |
What you will have at the end
- A Terraform repository that builds the whole system and tears it down
- A PITR drill with your measured restore time
- A weekly automated restore test with a
RestoreSecondsmetric you can chart - A
RUNBOOK.mdfor a regional outage, with your measured RTO and RPO filled in
What this build leaves out
This protects the data layer only. A full recovery plan also needs the application tier, DNS failover and a decision process for when to fail over. This lab uses a single-AZ database and the default VPC in Mumbai to stay cheap, so production would add Multi-AZ and private subnets.
Prerequisites and cost
You need an AWS account with administrator access, Terraform 1.10 or later, AWS CLI v2 and the psql client (postgresql-client). Billable pieces are a small RDS instance, replicated backup storage, a NAT gateway, two KMS keys, a secret, and CodeBuild and test-instance minutes during each test. Budget a few dollars for a weekend and run the destroy steps at the end.
Milestone 1: Set Up Terraform, Keys and the Database Secret
This milestone creates the project, the two regional providers, an encryption key in each region and a generated database password that is replicated to Singapore.
KMS keys never leave their region. A backup encrypted with the Mumbai key cannot be read in Singapore, so replication needs a second key in Singapore. The password is generated by Terraform and stored in Secrets Manager with a replica in Singapore, so the recovery job can read it even if Mumbai is unreachable.
mkdir rds-backup-dr && cd rds-backup-dr && git initprintf ".terraform/\n*.tfstate\n*.tfstate.*\n*.tfplan\nterraform.tfvars\n" > .gitignoreCreate versions.tf:
terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } random = { source = "hashicorp/random" version = "~> 3.6" } }} provider "aws" { alias = "primary" region = var.primary_region default_tags { tags = { Project = "rds-dr", ManagedBy = "terraform" } }} provider "aws" { alias = "dr" region = var.dr_region default_tags { tags = { Project = "rds-dr", ManagedBy = "terraform" } }}Create variables.tf:
variable "primary_region" { type = string default = "ap-south-1"} variable "dr_region" { type = string default = "ap-southeast-1"} variable "db_identifier" { type = string default = "production-postgres"} variable "db_instance_class" { type = string default = "db.t4g.micro"} variable "backup_retention_days" { description = "Must be 1 or more for backups, PITR and replication to exist" type = number default = 14} variable "admin_cidr" { description = "Your IP in CIDR form. Only this address can reach the lab database" type = string} variable "alert_email" { type = string} variable "protect_database" { description = "Deletion protection and a final snapshot. Set false before destroying the lab" type = bool default = true}Create security.tf:
resource "aws_kms_key" "primary" { provider = aws.primary description = "RDS encryption key, primary region" enable_key_rotation = true deletion_window_in_days = 7} resource "aws_kms_key" "dr" { provider = aws.dr description = "RDS encryption key, DR region" enable_key_rotation = true deletion_window_in_days = 7} resource "random_password" "db" { length = 24 special = false # RDS rejects some special characters and shells dislike others} resource "aws_secretsmanager_secret" "db" { provider = aws.primary name = "${var.db_identifier}/master-password" kms_key_id = aws_kms_key.primary.arn recovery_window_in_days = 0 # lab setting: delete immediately on destroy replica { region = var.dr_region kms_key_id = aws_kms_key.dr.arn }} resource "aws_secretsmanager_secret_version" "db" { provider = aws.primary secret_id = aws_secretsmanager_secret.db.id secret_string = random_password.db.result}Provide your values and create these pieces:
cat > terraform.tfvars <<EOFadmin_cidr = "$(curl -s https://checkip.amazonaws.com | tr -d '\n')/32"alert_email = "you@example.com"EOF terraform initterraform fmt && terraform validateterraform apply# Expected: 2 KMS keys, 1 secret with a replica, 1 secret versionSecurityThe generated password is stored in Terraform state. Keep state private, and in a team setting use a remote encrypted backend like the one in the EKS project.
Common MistakeReusing one KMS key ARN for both regions. KMS keys are regional, so the replication or restore fails with a key-not-found error. Create one key per region.
Milestone 2: Deploy the Primary Database and Seed It
This milestone creates an encrypted PostgreSQL instance with automated backups and loads sample data you can check after every restore.
The setting that makes everything else possible is backup_retention_period. Above zero, RDS takes a daily snapshot and uploads transaction logs about every five minutes, which is what allows PITR. At zero there are no automated backups, no PITR and nothing to replicate.
Create database.tf:
data "aws_vpc" "default" { provider = aws.primary default = true} resource "aws_security_group" "db" { provider = aws.primary name = "${var.db_identifier}-sg" description = "PostgreSQL from the admin IP only" vpc_id = data.aws_vpc.default.id ingress { description = "Admin access for seeding and drills" from_port = 5432 to_port = 5432 protocol = "tcp" cidr_blocks = [var.admin_cidr] }} resource "aws_db_instance" "primary" { provider = aws.primary identifier = var.db_identifier engine = "postgres" engine_version = "16" instance_class = var.db_instance_class allocated_storage = 20 storage_type = "gp3" storage_encrypted = true kms_key_id = aws_kms_key.primary.arn db_name = "appdb" username = "postgres" password = random_password.db.result vpc_security_group_ids = [aws_security_group.db.id] publicly_accessible = true # lab only, so you can seed from your laptop # Backups and point-in-time recovery backup_retention_period = var.backup_retention_days backup_window = "20:30-21:30" # 02:00 to 03:00 IST copy_tags_to_snapshot = true enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"] auto_minor_version_upgrade = true apply_immediately = true deletion_protection = var.protect_database skip_final_snapshot = !var.protect_database final_snapshot_identifier = var.protect_database ? "${var.db_identifier}-final" : null lifecycle { ignore_changes = [engine_version] # minor upgrades happen automatically }}Create outputs.tf:
output "db_endpoint" { value = aws_db_instance.primary.address} output "db_sg_id" { value = aws_security_group.db.id} output "secret_name" { value = aws_secretsmanager_secret.db.name}terraform apply# Takes about 10 minutes. Expected: the instance is created and availableCreate seed.sql:
CREATE TABLE users ( id serial PRIMARY KEY, email text UNIQUE NOT NULL, created_at timestamptz DEFAULT now()); CREATE TABLE orders ( id serial PRIMARY KEY, user_id int NOT NULL REFERENCES users(id), amount_cents int NOT NULL, created_at timestamptz DEFAULT now()); INSERT INTO users (email)SELECT 'user' || g || '@example.com' FROM generate_series(1, 1000) g; INSERT INTO orders (user_id, amount_cents)SELECT (random() * 999 + 1)::int, (random() * 50000)::int FROM generate_series(1, 5000);Load it, reading the password from Secrets Manager so it never appears in a file:
export AWS_REGION=ap-south-1HOST=$(terraform output -raw db_endpoint)export PGPASSWORD=$(aws secretsmanager get-secret-value \ --secret-id production-postgres/master-password \ --query SecretString --output text) psql "host=$HOST dbname=appdb user=postgres sslmode=require" -f seed.sqlpsql "host=$HOST dbname=appdb user=postgres sslmode=require" -tA \ -c "SELECT count(*) FROM users" -c "SELECT count(*) FROM orders"# Expected: 1000 and 5000Security
publicly_accessible = truewith a single-IP security group is acceptable for a short lab only. Real production databases sit in private subnets with no public endpoint.
Common MistakeSetting
backup_retention_period = 0to save money. RDS then keeps no automated backups, PITR is impossible and cross-region replication cannot start. Keep it at 1 or more.
Milestone 3: Replicate Backups to the DR Region
This milestone copies the database's automated backups, including transaction logs, to Singapore, encrypted with the Singapore key.
Automated backup replication is an RDS feature that keeps a second copy of your snapshots and logs in another region. Because the logs are copied too, you can do point-in-time recovery in the DR region, not only restore the last snapshot. It also removes the need for a custom snapshot-copy script, which is one less thing to fail silently.
Create replication.tf:
resource "aws_db_instance_automated_backups_replication" "dr" { provider = aws.dr source_db_instance_arn = aws_db_instance.primary.arn kms_key_id = aws_kms_key.dr.arn retention_period = var.backup_retention_days}terraform apply aws rds describe-db-instance-automated-backups --region ap-southeast-1 \ --query 'DBInstanceAutomatedBackups[*].[DBInstanceIdentifier,Status,RestoreWindow.EarliestTime,RestoreWindow.LatestTime]' \ --output table# Expected: production-postgres with Status replicatingThe first restore point in Singapore can take from minutes to a few hours to appear, because replication starts from the next automated backup. An empty restore window early on is normal.
TipIf Terraform reports an error starting replication for an encrypted instance, start it with the CLI instead, which handles the cross-region request for you:
aws rds start-db-instance-automated-backups-replication --region ap-southeast-1 --source-db-instance-arn SOURCE_ARN --kms-key-id DR_KEY_ARN --backup-retention-period 14. Then import the result or manage replication outside Terraform.
Common MistakeTreating the replication resource as proof of recovery. A backup you have never restored is only a belief. The restore test in Milestone 7 is what turns it into evidence.
Milestone 4: Add Alerts for Failures
This milestone makes problems loud: emails for backup failures, low storage and, later, failed restore tests.
You need an SNS topic in each region. EventBridge and CloudWatch can only publish to a topic in their own region, and the restore test lives in Singapore. Each email subscription must be confirmed once from the link AWS sends.
Create alerts.tf:
resource "aws_sns_topic" "primary" { provider = aws.primary name = "rds-dr-alerts"} resource "aws_sns_topic_subscription" "primary_email" { provider = aws.primary topic_arn = aws_sns_topic.primary.arn protocol = "email" endpoint = var.alert_email} resource "aws_sns_topic" "dr" { provider = aws.dr name = "rds-dr-alerts"} resource "aws_sns_topic_subscription" "dr_email" { provider = aws.dr topic_arn = aws_sns_topic.dr.arn protocol = "email" endpoint = var.alert_email} # Backup failures, low storage and recovery events on the primary databaseresource "aws_db_event_subscription" "primary" { provider = aws.primary name = "${var.db_identifier}-events" sns_topic = aws_sns_topic.primary.arn source_type = "db-instance" source_ids = [aws_db_instance.primary.identifier] event_categories = ["backup", "failure", "low storage", "recovery"]} resource "aws_cloudwatch_metric_alarm" "low_storage" { provider = aws.primary alarm_name = "${var.db_identifier}-low-storage" alarm_description = "Free storage is below 2 GB, backups and writes may fail" namespace = "AWS/RDS" metric_name = "FreeStorageSpace" statistic = "Average" period = 300 evaluation_periods = 1 comparison_operator = "LessThanThreshold" threshold = 2147483648 alarm_actions = [aws_sns_topic.primary.arn] dimensions = { DBInstanceIdentifier = aws_db_instance.primary.identifier }}terraform apply# Then open both confirmation emails and click Confirm subscriptionCommon MistakeNever confirming the SNS subscription emails. The topic exists, the alarms fire, and nobody receives anything. Confirm both subscriptions and check the subscription status shows a real ARN.
Milestone 5: Rehearse Point-in-Time Recovery
This milestone simulates a bad migration and recovers from it, so you have done the most common real recovery before you need it.
A restore never overwrites the running database. RDS always creates a new instance from the chosen moment. The usual pattern is to restore beside production, inspect it, copy the missing rows back, then delete the copy. Switching the application to the restored instance is a last resort, because it changes the endpoint and loses anything written after your chosen time.
export AWS_REGION=ap-south-1HOST=$(terraform output -raw db_endpoint)DB_SG=$(terraform output -raw db_sg_id)export PGPASSWORD=$(aws secretsmanager get-secret-value \ --secret-id production-postgres/master-password \ --query SecretString --output text)PSQL="psql host=$HOST dbname=appdb user=postgres sslmode=require -tA" # 1. Note a good moment, then wait a minuteGOOD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)echo "Good time: $GOOD_TIME"sleep 60 # 2. The "bad migration"$PSQL -c "DELETE FROM orders WHERE amount_cents < 25000"$PSQL -c "SELECT count(*) FROM orders"# Expected: roughly half of the 5000 orders are goneWait until the delete is part of the restorable window, then restore to the moment before it:
aws rds describe-db-instances --db-instance-identifier production-postgres \ --query 'DBInstances[0].LatestRestorableTime' --output text# Wait until this time is later than the delete (usually about 5 minutes) START=$(date +%s)aws rds restore-db-instance-to-point-in-time \ --source-db-instance-identifier production-postgres \ --target-db-instance-identifier pitr-drill \ --restore-time "$GOOD_TIME" \ --db-instance-class db.t4g.micro \ --vpc-security-group-ids "$DB_SG" \ --publicly-accessible aws rds wait db-instance-available --db-instance-identifier pitr-drillecho "Restore took $(( $(date +%s) - START )) seconds" PITR_HOST=$(aws rds describe-db-instances --db-instance-identifier pitr-drill \ --query 'DBInstances[0].Endpoint.Address' --output text)psql "host=$PITR_HOST dbname=appdb user=postgres sslmode=require" -tA \ -c "SELECT count(*) FROM orders"# Expected: 5000, the rows from before the bad deleteRecord your restore time, because it is your first real RTO measurement. Then remove the drill instance:
aws rds delete-db-instance --db-instance-identifier pitr-drill \ --skip-final-snapshot --delete-automated-backupsTipRestoring beside production and copying rows back (for example with
pg_dumpof just the affected table) keeps new writes safe. Use a restored instance as the new primary only when the original is unrecoverable.
Common MistakeChoosing a restore time later than
LatestRestorableTimeand getting an invalid restore time error. CheckLatestRestorableTimefirst. It usually trails real time by about five minutes.
Milestone 6: Build the Network in the DR Region
This milestone creates a VPC in Singapore that is ready before any disaster, with private subnets for restored databases and a NAT gateway so the test runner can reach AWS APIs.
You cannot calmly design networking in the middle of an outage, so the DR region needs its network built and tested in advance. The restored database goes in private subnets and accepts connections only from the test runner's security group.
Create dr-network.tf:
data "aws_availability_zones" "dr" { provider = aws.dr state = "available"} resource "aws_vpc" "dr" { provider = aws.dr cidr_block = "10.20.0.0/16" enable_dns_support = true enable_dns_hostnames = true tags = { Name = "dr-vpc" }} resource "aws_internet_gateway" "dr" { provider = aws.dr vpc_id = aws_vpc.dr.id tags = { Name = "dr-igw" }} resource "aws_subnet" "dr_public" { provider = aws.dr vpc_id = aws_vpc.dr.id cidr_block = cidrsubnet(aws_vpc.dr.cidr_block, 8, 0) availability_zone = data.aws_availability_zones.dr.names[0] tags = { Name = "dr-public" }} resource "aws_subnet" "dr_private" { count = 2 provider = aws.dr vpc_id = aws_vpc.dr.id cidr_block = cidrsubnet(aws_vpc.dr.cidr_block, 8, count.index + 10) availability_zone = data.aws_availability_zones.dr.names[count.index] tags = { Name = "dr-private-${count.index}" }} resource "aws_eip" "dr_nat" { provider = aws.dr domain = "vpc"} resource "aws_nat_gateway" "dr" { provider = aws.dr allocation_id = aws_eip.dr_nat.id subnet_id = aws_subnet.dr_public.id depends_on = [aws_internet_gateway.dr] tags = { Name = "dr-nat" }} resource "aws_route_table" "dr_public" { provider = aws.dr vpc_id = aws_vpc.dr.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.dr.id }} resource "aws_route_table_association" "dr_public" { provider = aws.dr subnet_id = aws_subnet.dr_public.id route_table_id = aws_route_table.dr_public.id} resource "aws_route_table" "dr_private" { provider = aws.dr vpc_id = aws_vpc.dr.id route { cidr_block = "0.0.0.0/0" nat_gateway_id = aws_nat_gateway.dr.id }} resource "aws_route_table_association" "dr_private" { count = 2 provider = aws.dr subnet_id = aws_subnet.dr_private[count.index].id route_table_id = aws_route_table.dr_private.id} resource "aws_db_subnet_group" "dr" { provider = aws.dr name = "dr-restore" subnet_ids = aws_subnet.dr_private[*].id} resource "aws_security_group" "dr_runner" { provider = aws.dr name = "dr-test-runner" description = "CodeBuild restore test runner" vpc_id = aws_vpc.dr.id egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }} resource "aws_security_group" "dr_db" { provider = aws.dr name = "dr-restored-db" description = "PostgreSQL from the test runner only" vpc_id = aws_vpc.dr.id ingress { from_port = 5432 to_port = 5432 protocol = "tcp" security_groups = [aws_security_group.dr_runner.id] }}terraform apply# Expected: VPC, subnets, NAT gateway, subnet group and two security groups createdTipThe NAT gateway is the priciest part of this lab by the hour. A real DR network often keeps one running, and you can destroy the lab at the end to stop the charge.
Common MistakePutting the test runner in a private subnet with no NAT or VPC endpoints. The build then hangs because it cannot reach the RDS, Secrets Manager or CloudWatch APIs. Give private subnets a route out.
Milestone 7: Automate the Weekly Restore Test
This milestone creates a CodeBuild job that restores the replicated backup in Singapore, queries the data, records the restore time and deletes the test database. A schedule runs it every Sunday.
CodeBuild fits because a restore takes longer than a Lambda function can run, and because it can run inside your VPC to reach a private database. The test passes only if the restored database answers real queries. A restore that merely reaches "available" proves little.
Create buildspec-dr-test.yml:
version: 0.2 env: shell: bash phases: build: commands: - | set -euo pipefail dnf install -y postgresql15 > /dev/null 2>&1 || dnf install -y postgresql16 > /dev/null psql --version TEST_ID="dr-test-$(date -u +%Y%m%d-%H%M)" cleanup() { aws rds delete-db-instance --db-instance-identifier "$TEST_ID" \ --skip-final-snapshot --delete-automated-backups \ --region "$DR_REGION" > /dev/null 2>&1 || true } trap cleanup EXIT BACKUP_ARN=$(aws rds describe-db-instance-automated-backups --region "$DR_REGION" \ --query "DBInstanceAutomatedBackups[?DBInstanceIdentifier=='$DB_ID'] | [0].DBInstanceAutomatedBackupsArn" \ --output text) if [ -z "$BACKUP_ARN" ] || [ "$BACKUP_ARN" = "None" ]; then echo "No replicated backup found for $DB_ID" exit 1 fi LATEST=$(aws rds describe-db-instance-automated-backups --region "$DR_REGION" \ --query "DBInstanceAutomatedBackups[?DBInstanceIdentifier=='$DB_ID'] | [0].RestoreWindow.LatestTime" \ --output text) echo "Latest restorable time in DR region: $LATEST" START=$(date +%s) aws rds restore-db-instance-to-point-in-time \ --region "$DR_REGION" \ --source-db-instance-automated-backups-arn "$BACKUP_ARN" \ --target-db-instance-identifier "$TEST_ID" \ --use-latest-restorable-time \ --db-instance-class "$TEST_CLASS" \ --db-subnet-group-name "$SUBNET_GROUP" \ --vpc-security-group-ids "$DB_SG_ID" \ --kms-key-id "$KMS_KEY_ARN" \ --no-publicly-accessible \ --no-multi-az \ --tags Key=Purpose,Value=dr-test > /dev/null aws rds wait db-instance-available --db-instance-identifier "$TEST_ID" --region "$DR_REGION" ELAPSED=$(( $(date +%s) - START )) echo "Restore reached available in $ELAPSED seconds" ENDPOINT=$(aws rds describe-db-instances --db-instance-identifier "$TEST_ID" \ --region "$DR_REGION" --query 'DBInstances[0].Endpoint.Address' --output text) export PGPASSWORD=$(aws secretsmanager get-secret-value --secret-id "$SECRET_NAME" \ --region "$DR_REGION" --query SecretString --output text) Q() { psql "host=$ENDPOINT dbname=appdb user=postgres sslmode=require connect_timeout=30" -tA -c "$1"; } USERS=$(Q "SELECT count(*) FROM users") ORDERS=$(Q "SELECT count(*) FROM orders") ORPHANS=$(Q "SELECT count(*) FROM orders o LEFT JOIN users u ON u.id = o.user_id WHERE u.id IS NULL") echo "users=$USERS orders=$ORDERS orphaned_orders=$ORPHANS" PASSED=0 if [ "$USERS" -gt 0 ] && [ "$ORDERS" -gt 0 ] && [ "$ORPHANS" -eq 0 ]; then PASSED=1; fi aws cloudwatch put-metric-data --namespace DisasterRecovery \ --metric-name RestoreSeconds --value "$ELAPSED" --unit Seconds --region "$DR_REGION" aws cloudwatch put-metric-data --namespace DisasterRecovery \ --metric-name ValidationPassed --value "$PASSED" --unit Count --region "$DR_REGION" [ "$PASSED" -eq 1 ]Create dr-test.tf:
data "aws_caller_identity" "current" { provider = aws.primary} resource "aws_cloudwatch_log_group" "dr_test" { provider = aws.dr name = "/aws/codebuild/rds-dr-restore-test" retention_in_days = 30} data "aws_iam_policy_document" "codebuild_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["codebuild.amazonaws.com"] } }} resource "aws_iam_role" "dr_test" { provider = aws.dr name = "rds-dr-restore-test" assume_role_policy = data.aws_iam_policy_document.codebuild_assume.json} data "aws_iam_policy_document" "dr_test" { statement { sid = "Logs" actions = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"] resources = ["${aws_cloudwatch_log_group.dr_test.arn}:*"] } statement { sid = "RestoreAndCleanUp" actions = [ "rds:RestoreDBInstanceToPointInTime", "rds:DescribeDBInstances", "rds:DescribeDBInstanceAutomatedBackups", "rds:DeleteDBInstance", "rds:AddTagsToResource", ] resources = ["*"] # scope this down by ARN in a real account } statement { sid = "ReadReplicatedSecret" actions = ["secretsmanager:GetSecretValue"] resources = ["arn:aws:secretsmanager:${var.dr_region}:${data.aws_caller_identity.current.account_id}:secret:${var.db_identifier}/master-password-*"] } statement { sid = "UseDrKey" actions = ["kms:Decrypt", "kms:DescribeKey", "kms:GenerateDataKey*", "kms:CreateGrant", "kms:ReEncrypt*"] resources = [aws_kms_key.dr.arn] } statement { sid = "PublishMetrics" actions = ["cloudwatch:PutMetricData"] resources = ["*"] condition { test = "StringEquals" variable = "cloudwatch:namespace" values = ["DisasterRecovery"] } } statement { sid = "VpcNetworking" actions = [ "ec2:CreateNetworkInterface", "ec2:DescribeNetworkInterfaces", "ec2:DeleteNetworkInterface", "ec2:DescribeSubnets", "ec2:DescribeSecurityGroups", "ec2:DescribeDhcpOptions", "ec2:DescribeVpcs", ] resources = ["*"] } statement { sid = "VpcNetworkingPermission" actions = ["ec2:CreateNetworkInterfacePermission"] resources = ["arn:aws:ec2:${var.dr_region}:${data.aws_caller_identity.current.account_id}:network-interface/*"] condition { test = "StringEquals" variable = "ec2:AuthorizedService" values = ["codebuild.amazonaws.com"] } }} resource "aws_iam_role_policy" "dr_test" { provider = aws.dr name = "restore-test" role = aws_iam_role.dr_test.id policy = data.aws_iam_policy_document.dr_test.json} resource "aws_codebuild_project" "dr_test" { provider = aws.dr name = "rds-dr-restore-test" description = "Restores the replicated RDS backup, validates the data and deletes the test instance" service_role = aws_iam_role.dr_test.arn build_timeout = 90 artifacts { type = "NO_ARTIFACTS" } environment { compute_type = "BUILD_GENERAL1_SMALL" image = "aws/codebuild/amazonlinux-x86_64-standard:5.0" type = "LINUX_CONTAINER" environment_variable { name = "DR_REGION" value = var.dr_region } environment_variable { name = "DB_ID" value = var.db_identifier } environment_variable { name = "TEST_CLASS" value = var.db_instance_class } environment_variable { name = "SUBNET_GROUP" value = aws_db_subnet_group.dr.name } environment_variable { name = "DB_SG_ID" value = aws_security_group.dr_db.id } environment_variable { name = "KMS_KEY_ARN" value = aws_kms_key.dr.arn } environment_variable { name = "SECRET_NAME" value = aws_secretsmanager_secret.db.name } } vpc_config { vpc_id = aws_vpc.dr.id subnets = aws_subnet.dr_private[*].id security_group_ids = [aws_security_group.dr_runner.id] } logs_config { cloudwatch_logs { group_name = aws_cloudwatch_log_group.dr_test.name } } source { type = "NO_SOURCE" buildspec = file("${path.module}/buildspec-dr-test.yml") } depends_on = [aws_route_table_association.dr_private, aws_iam_role_policy.dr_test]} # Weekly schedule: Sunday 01:00 UTCdata "aws_iam_policy_document" "events_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["events.amazonaws.com"] } }} resource "aws_iam_role" "events_start_build" { provider = aws.dr name = "rds-dr-test-scheduler" assume_role_policy = data.aws_iam_policy_document.events_assume.json} resource "aws_iam_role_policy" "events_start_build" { provider = aws.dr name = "start-build" role = aws_iam_role.events_start_build.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = "codebuild:StartBuild" Resource = aws_codebuild_project.dr_test.arn }] })} resource "aws_cloudwatch_event_rule" "weekly_test" { provider = aws.dr name = "weekly-dr-restore-test" schedule_expression = "cron(0 1 ? * SUN *)"} resource "aws_cloudwatch_event_target" "weekly_test" { provider = aws.dr rule = aws_cloudwatch_event_rule.weekly_test.name arn = aws_codebuild_project.dr_test.arn role_arn = aws_iam_role.events_start_build.arn} # Alert when a test run fails, times out or is stoppedresource "aws_sns_topic_policy" "dr" { provider = aws.dr arn = aws_sns_topic.dr.arn policy = jsonencode({ Version = "2012-10-17" Statement = [{ Sid = "AllowEventsAndAlarms" Effect = "Allow" Principal = { Service = ["events.amazonaws.com", "cloudwatch.amazonaws.com"] } Action = "sns:Publish" Resource = aws_sns_topic.dr.arn }] })} resource "aws_cloudwatch_event_rule" "test_failed" { provider = aws.dr name = "dr-restore-test-failed" event_pattern = jsonencode({ source = ["aws.codebuild"] "detail-type" = ["CodeBuild Build State Change"] detail = { "build-status" = ["FAILED", "FAULT", "TIMED_OUT", "STOPPED"] "project-name" = [aws_codebuild_project.dr_test.name] } })} resource "aws_cloudwatch_event_target" "test_failed" { provider = aws.dr rule = aws_cloudwatch_event_rule.test_failed.name arn = aws_sns_topic.dr.arn} # Alert when no passing test has been recorded for 8 daysresource "aws_cloudwatch_metric_alarm" "no_recent_pass" { provider = aws.dr alarm_name = "dr-restore-test-not-passing" alarm_description = "No successful DR restore test in the last 8 days" namespace = "DisasterRecovery" metric_name = "ValidationPassed" statistic = "Sum" period = 86400 evaluation_periods = 8 datapoints_to_alarm = 8 comparison_operator = "LessThanThreshold" threshold = 1 treat_missing_data = "breaching" alarm_actions = [aws_sns_topic.dr.arn]}terraform fmt && terraform validateterraform apply# Expected: CodeBuild project, schedule, alert rules and alarm createdSecurityThe restore job can create and delete RDS instances. This lab grants those actions on
*for readability. In a real account, restrict them with resource ARNs and a tag condition so the job can only delete instances taggedPurpose=dr-test.
Common MistakeTreating "the instance became available" as a passing test. Plenty of restores come up and still hold the wrong or empty data. The job above queries real tables and fails the build when counts or integrity checks are off.
Milestone 8: Write the Runbook and Measure Your Numbers
This milestone turns what you built into a runbook someone could follow at 3 AM, with real numbers instead of guesses.
A runbook is a short list of commands in the order you would run them, with the expected result of each. Create RUNBOOK.md in the repository:
# Regional outage runbook: production-postgres Measured RTO (restore until database available): FILL_IN minutesMeasured RPO (age of newest restorable point in DR): FILL_IN minutes ## 1. Confirm the primary region is really unavailable## 2. Find the newest restorable point in Singapore## 3. Restore into the DR VPC## 4. Validate the data## 5. Repoint the application and announce## 6. Plan failbackFill in step 2 and step 3 with these commands:
DR=ap-southeast-1 aws rds describe-db-instance-automated-backups --region $DR \ --query "DBInstanceAutomatedBackups[?DBInstanceIdentifier=='production-postgres'] | [0].[DBInstanceAutomatedBackupsArn,RestoreWindow.LatestTime]" \ --output text aws rds restore-db-instance-to-point-in-time --region $DR \ --source-db-instance-automated-backups-arn BACKUP_ARN \ --target-db-instance-identifier production-postgres-dr \ --use-latest-restorable-time \ --db-instance-class db.t4g.micro \ --db-subnet-group-name dr-restore \ --vpc-security-group-ids DR_DB_SG_ID \ --kms-key-id DR_KEY_ARN \ --no-publicly-accessibleMeasure the two numbers from real data:
# RPO: how old is the newest restorable point in the DR region?LATEST=$(aws rds describe-db-instance-automated-backups --region ap-southeast-1 \ --query "DBInstanceAutomatedBackups[?DBInstanceIdentifier=='production-postgres'] | [0].RestoreWindow.LatestTime" \ --output text)echo "DR restore point is $(( ($(date +%s) - $(date -d "$LATEST" +%s)) / 60 )) minutes old" # RTO: read the RestoreSeconds metric published by the weekly testaws cloudwatch get-metric-statistics --region ap-southeast-1 \ --namespace DisasterRecovery --metric-name RestoreSeconds \ --statistics Maximum --period 604800 \ --start-time "$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)" \ --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)"TipTreat these as your own measurements for this database size and instance class, not a promise. They grow as the database grows, so re-measure after big data changes.
Common MistakeWriting a runbook that has never been followed. Run steps 2 and 3 once as a drill, fix whatever was unclear, and only then trust the document.
Validation & Testing
This final step proves each part works, then removes everything. Run the checks in order.
# 1. Automated backups are on and encrypted. Expected: [14, true]aws rds describe-db-instances --region ap-south-1 --db-instance-identifier production-postgres \ --query 'DBInstances[0].[BackupRetentionPeriod,StorageEncrypted]' # 2. Backups are replicated to Singapore. Expected: Status replicating and a restore windowaws rds describe-db-instance-automated-backups --region ap-southeast-1 \ --query 'DBInstanceAutomatedBackups[*].[DBInstanceIdentifier,Status,RestoreWindow.LatestTime]' \ --output table # 3. Both alert emails are confirmed. Expected: real subscription ARNs, not PendingConfirmationaws sns list-subscriptions-by-topic --region ap-south-1 \ --topic-arn "$(aws sns list-topics --region ap-south-1 --query "Topics[?contains(TopicArn,'rds-dr-alerts')].TopicArn | [0]" --output text)" \ --query 'Subscriptions[*].SubscriptionArn' # 4. Run the DR restore test now instead of waiting for SundayBUILD_ID=$(aws codebuild start-build --region ap-southeast-1 \ --project-name rds-dr-restore-test --query 'build.id' --output text)aws logs tail /aws/codebuild/rds-dr-restore-test --region ap-southeast-1 --follow# Expected: "users=1000 orders=5000 orphaned_orders=0" and a successful build aws codebuild batch-get-builds --region ap-southeast-1 --ids "$BUILD_ID" \ --query 'builds[0].buildStatus' --output text# Expected: SUCCEEDED # 5. The metrics were published. Expected: ValidationPassed 1 and a RestoreSeconds valueaws cloudwatch list-metrics --region ap-southeast-1 --namespace DisasterRecovery # 6. The test database cleaned itself up. Expected: no dr-test-* instanceaws rds describe-db-instances --region ap-southeast-1 \ --query 'DBInstances[?starts_with(DBInstanceIdentifier,`dr-test`)].DBInstanceIdentifier'Prove the failure path too. Temporarily break the test, for example by changing the users table name in the query in buildspec-dr-test.yml, apply, run the build again, and confirm it fails and you receive the alert email. Then revert the change. An alert you have never seen fire is untested.
Clean up
Delete the replicated backups and instances last, because they cost money while they exist.
# Allow deletion, then destroy everything Terraform createdterraform apply -var protect_database=falseterraform destroy # Replicated backups can outlive the replication. List and delete any that remainaws rds describe-db-instance-automated-backups --region ap-southeast-1 \ --query 'DBInstanceAutomatedBackups[*].[DbiResourceId,Status]' --output textaws rds delete-db-instance-automated-backups --region ap-southeast-1 --dbi-resource-id RESOURCE_IDThe KMS keys are scheduled for deletion after 7 days, so they stop billing then.
Common MistakeRunning
terraform destroywhileprotect_databaseis still true. Deletion protection blocks the destroy. Apply withprotect_database=falsefirst.
Commit the repository with RUNBOOK.md, a screenshot of the passing restore test and your measured RTO and RPO. A reviewer can see that your backups are replicated, tested and monitored, not just configured.