AWS Lesson 90 of 123

AWS Enterprise Architecture: Active-Active Multi-Region

In a nutshell

Imagine a restaurant chain that runs two identical, fully-staffed kitchens — one in Virginia, one in Ireland — both open and serving real customers at the same time, off the same live menu and the same shared order book. If the Virginia kitchen loses power, nobody in the queue notices: Ireland was already cooking, and it simply takes the extra tables. That is active-active multi-region. Contrast it with keeping a dark second kitchen that you’d have to unlock, staff, and warm up during the fire — that’s a standby, and the fire is a terrible time to discover the freezer was empty.

“Multi-region” means running your application in two or more AWS Regions (a Region is a whole geographic cluster of data centres, like us-east-1 in Virginia or eu-west-1 in Ireland). The point is to survive the loss of an entire Region — not just one server or one data centre, but the whole thing — with users barely noticing. The hard part is never “turn on a second copy.” The hard part is data: keeping two live copies of your database in sync across an ocean without them corrupting each other, and knowing exactly where you’re allowed to say “this data might be a second or two stale” versus “this must never be wrong.”

This lesson walks the full spectrum of disaster-recovery strategies — from cheap nightly backups up to always-on active-active — then builds the active-active pattern in depth on AWS using Route 53, CloudFront, DynamoDB Global Tables, and Aurora Global Database. Along the way we tackle the genuinely tricky bits: conflict resolution, idempotency, the “split-brain” problem, and the blunt question every architect must answer honestly — do you actually need this, or is multi-AZ in one Region already enough?

Level: Advanced · Time: ~65 min

Prerequisites — you’ll get the most from this if you already understand:

After this lesson you will be able to:

Most teams reach for “multi-region” the moment a board asks “what happens if a region goes down?” — and then discover that the honest answer depends entirely on how you went multi-region. A warm standby that nobody has failed over to in 18 months is not resilience; it is a liability with a runbook. This article is about the harder, more useful pattern: active-active, where both regions serve live production traffic all the time, so a regional loss is a capacity event rather than a heroic, untested failover.

We will build this on AWS with the four services that make active-active actually feasible without a self-managed replication layer: Amazon Route 53 for global traffic steering and health-based failover, Amazon CloudFront for edge delivery and origin failover, Amazon DynamoDB Global Tables for multi-active key-value/document data, and Amazon Aurora Global Database for relational data that needs a single writer but cross-region read scale and fast promotion. The interesting engineering is not in turning these on — it is in the boundaries between them: where you accept eventual consistency, where you refuse to, and how you keep two live regions from corrupting each other.

The business scenario

The pattern fits a surprisingly wide band of organisations, because the driver is rarely raw scale — it is revenue-per-minute, regulatory geography, or contractual availability.

What unites them is the same uncomfortable realisation: the failure modes that hurt are correlated and regional. A bad deployment, a control-plane event, an AZ-spanning power or networking incident, or a throttling storm tends to take out a region’s worth of a service at once, not one server. Multi-AZ (which you should already have) protects against a data-centre; it does nothing for a regional control-plane degradation or a fat-fingered region-wide config change.

The problem this architecture solves: serve every user from a healthy region with low latency, survive the loss of an entire region with an RTO measured in minutes and an RPO measured in seconds, and do it as a steady-state property of the system rather than a fire-drill. The cost is real — roughly 1.7–2.1x the single-region infrastructure bill plus meaningful inter-region data-transfer charges — so the rest of this article is also about deciding which parts of your system genuinely need it.

The disaster-recovery strategy spectrum

Before we build active-active, place it on the map. AWS describes four disaster-recovery strategies, and they form a spectrum where each step down cuts your recovery time and data loss but raises your bill and your complexity. Active-active is the far, expensive end — and choosing it without understanding the cheaper three is how teams over-spend.

A quick but crucial framing first: the two numbers that define any DR posture are RTO and RPO.

Everything below is an exercise in buying down RTO and RPO with money.

Strategy How it works Typical RTO Typical RPO Relative cost Good for
Backup & restore Back data up to another Region (S3 with CRR, AMI/EBS snapshot copy, RDS snapshot copy). On disaster, build the environment from scratch (IaC) and restore data. Hours Hours (since last backup) $ (lowest) Non-critical/internal apps; tolerable long outages
Pilot light Core data is live-replicated to the second Region and a minimal always-on footprint exists (e.g. a running DB replica). Servers exist as AMIs/templates but are switched off. On disaster, turn on and scale up compute, then repoint traffic. Tens of minutes Minutes–seconds $$ Important apps where minutes-of-recovery is fine
Warm standby A scaled-down but fully functional copy runs continuously in the second Region — real servers, real DB replica, real load balancer — just under-sized. On disaster, scale it up to full capacity and shift traffic. Minutes Seconds $$$ Business-critical apps needing fast, reliable recovery
Multi-site active-active Full production runs in every Region simultaneously, all serving live traffic. A Region loss is a capacity event, not a recovery event. Near-zero Near-zero $$$$ (highest) Contractual 99.99%+, quantifiable per-minute loss, multi-continent write latency

Read the table top-to-bottom as “how ready is the second Region when disaster strikes?” — from “nothing running, rebuild it” to “already running full and taking traffic.” The more you pre-build, the faster you recover and the more you pay for idle-ish capacity.

Two things beginners routinely get wrong about this spectrum:

1. Multi-AZ is not on this spectrum — it’s the floor underneath all of it. Every strategy above assumes each Region is already Multi-AZ (spread across ≥2 Availability Zones). Multi-AZ protects you from losing a data centre; it is automatic, cheap, and non-negotiable for production. Multi-region DR protects you from losing an entire Region. They are different blast radii, and multi-region is a supplement to Multi-AZ, never a replacement. If someone proposes multi-region for a service that isn’t even Multi-AZ yet, stop — fix the floor first.

2. Pilot light and warm standby are active-passive; only the fourth is active-active. In the middle two, the second Region does not serve production traffic until you fail over — it’s a warmed-up spare. That makes them dramatically cheaper (you’re not running two full fleets) but means recovery involves a deliberate failover action whose correctness you only find out about when you actually do it. Active-active removes the failover step for most of the stack because both Regions were always live — which is exactly why the rest of this lesson spends so much effort on data, since keeping two live copies honest is the price of that convenience.

The rest of this article builds the top row. But keep the whole table in your head, because the most valuable output of a multi-region design review is often the sentence “actually, warm standby is enough here.”

Architecture overview

Picture two AWS Regions running the same stack — call them us-east-1 (Virginia) as the primary and eu-west-1 (Ireland) as the secondary, though “primary/secondary” only matters for the relational tier; everything else is genuinely symmetric.

Active-active AWS multi-region architecture: Route 53, CloudFront and Global Accelerator steer traffic to two live Regions (us-east-1 and eu-west-1); each Region runs WAF+ALB and EKS/Fargate compute over DynamoDB and Aurora, with DynamoDB Global Tables replicating bidirectionally, Aurora Global Database replicating one-way to the promotable secondary, and S3 cross-region replication.

The request path, edge inward:

  1. A user resolves app.example.com. Route 53 answers with a latency-based (or geoproximity) routing policy, returning the entry point for the AWS Region closest to them. Each record is tied to a Route 53 health check so an unhealthy Region is withdrawn from DNS automatically.
  2. The resolved endpoint is a CloudFront distribution. Static assets and cacheable API responses are served from the edge. CloudFront is configured with an origin group: a primary origin (the nearest Region’s regional entry point) and a secondary origin, so that even before DNS TTLs expire, CloudFront can fail a single request over to the other Region on a 5xx/connection error.
  3. Dynamic requests hit the Region’s Application Load Balancer, fronted by AWS WAF, terminating TLS via ACM. The ALB distributes to the compute tier — ECS Fargate or EKS tasks (or Lambda behind API Gateway for the event-driven slices) — running identical container images in both Regions, deployed from one pipeline.
  4. The application reads and writes data through two distinct data planes, and this split is the heart of the design:
    • DynamoDB Global Tables for data that is naturally key-addressable and tolerant of last-writer-wins semantics — sessions, user profiles, carts, feature flags, event ledgers, idempotency keys. Each Region writes to its local replica; DynamoDB asynchronously replicates both directions, typically within a second or two. Both Regions are writers. There is no failover for this tier — it is already multi-active.
    • Aurora Global Database (PostgreSQL- or MySQL-compatible) for relational data needing strong consistency, foreign keys, and transactions — orders, invoices, ledgers-of-record, anything where “last writer wins” would be a financial bug. Here there is exactly one writer Region at a time. The secondary Region holds a read-only replica kept current via Aurora’s storage-layer replication (typically under one second of lag, often ~tens of milliseconds). Local reads are fast; writes from the secondary Region are routed back to the primary writer (cross-region) or, for write-heavy local needs, handled via write forwarding.
  5. CloudFront, DynamoDB Global Tables, and Route 53 are all global services — they do not themselves “fail over.” Only the relational writer and the regional compute fleets have a concept of primary/secondary.

The data path under steady state: a write in Ireland to a DynamoDB-backed feature flag is acknowledged locally in single-digit milliseconds and shows up in Virginia ~1–2s later. A write in Ireland to an Aurora-backed order is either forwarded to the Virginia writer (adding one cross-Atlantic round trip, ~70–90ms) or, if Ireland is the writer, committed locally and streamed to Virginia.

The failure path: if Virginia degrades, Route 53 health checks flip DNS so new users resolve to Ireland; CloudFront origin failover catches in-flight requests immediately; the DynamoDB tier needs no action because Ireland was always a writer; and the operator (or an automated runbook) promotes the Aurora secondary in Ireland to writer — a managed planned failover completes in about a minute with near-zero data loss, while an unplanned failover (region truly gone) is a few minutes with an RPO usually under a second.

The single most important design decision visible in this overview: DynamoDB removes failover from the equation for everything you can model as key-value; Aurora concentrates the only real failover risk into one well-understood, well-tooled operation. Minimise what lives in the Aurora “single-writer” world and active-active gets dramatically simpler.

Global traffic steering in depth

The architecture overview above showed traffic entering through Route 53, CloudFront, and optionally Global Accelerator. Those three are not interchangeable — they operate at different layers and fail over at very different speeds. Getting this layer right is what turns “we have two Regions” into “users actually reach the healthy one, fast.” For the DNS mechanics underneath this, the Route 53 routing & health-checks lesson goes deeper on record types; here we focus on the multi-region decisions.

Route 53 routing policies — which one, and why

Route 53 offers several routing policies. For multi-region you’ll use a small subset, and mixing them per record set is normal:

Policy What it does Multi-region use
Latency-based Returns the Region that gives the resolver the lowest measured latency Default for active-active — send each user to their nearest healthy Region
Geoproximity Routes by geographic distance, with an adjustable bias to grow/shrink a Region’s “pull” When you want to shift a share of traffic between Regions (e.g. bleed load off a Region)
Geolocation Routes by the user’s location (continent/country) Data-residency requirements (EU users must hit the EU Region)
Failover Primary/secondary: serve primary while healthy, else secondary Active-passive (warm standby) tiers
Weighted Splits traffic by assigned weights Canary/gradual cutover; blue-green across Regions
Multivalue answer Returns up to 8 healthy records, client picks Simple health-aware round-robin; not a load balancer

The policy is only half the story — health checks are the other half, and they are where multi-region DNS earns or loses its reputation. Attach a Route 53 health check to each Region’s record so an unhealthy Region is withdrawn from DNS automatically. Three things matter:

The uncomfortable truth about DNS failover speed

DNS is cached, and that cache is not yours to control. Even if you set a 60-second TTL on your record, real-world resolvers (corporate DNS, mobile carriers, some browsers) routinely ignore or extend TTLs, and clients that resolved just before the outage keep hammering the dead Region for minutes. So the practical failover time for DNS is “your TTL, plus a long tail of misbehaving resolvers” — often several minutes, sometimes more.

Worked example — why TTL math misleads you:

Record TTL:            60 seconds
Your assumption:       "failover in ~1 minute"
Reality:               well-behaved resolvers re-resolve in ≤60s,
                       but a meaningful % of clients cache 300s–3600s,
                       and some pin the old A record until app restart.
Effective RTO (DNS):   2–10+ minutes for the tail

For a cacheable web SPA, a few minutes is fine. For a latency-critical, non-cacheable API — a payment authorisation, a game session — it is not. That is precisely why the next two tools exist.

CloudFront origin failover — sub-DNS, for cacheable and semi-dynamic paths

CloudFront lets you define an origin group: a primary origin and a secondary. If the primary returns a configured failure (500/502/503/504 or a connection error), CloudFront retries the same request against the secondary origin — no DNS change, no TTL wait, on the order of seconds for the in-flight request. It’s the fastest fix for requests that already reached the edge, and it complements DNS: DNS steers new resolvers to the healthy Region over minutes, while CloudFront catches in-flight requests immediately.

Global Accelerator — anycast for the paths DNS can’t fail over fast enough

AWS Global Accelerator gives you two static anycast IP addresses that are advertised from AWS edge locations worldwide. Clients connect to those same two IPs no matter which Region ultimately serves them; Global Accelerator routes the connection over the AWS backbone to the nearest healthy endpoint group (one per Region). Because the client’s IP never changes, failover doesn’t depend on DNS caching at all — when a Region’s endpoints go unhealthy, Global Accelerator reroutes on the backbone in roughly 30 seconds or less. It also exposes traffic dials (0–100% per endpoint group) so you can deliberately drain a Region. Use it in front of TCP/UDP or non-cacheable APIs where the DNS tail is unacceptable; use CloudFront where you also want edge caching. They can even be layered.

Route 53 Application Recovery Controller (ARC) — making failover a switch, not a hope

Health-check-driven DNS failover has a nasty failure mode: during a partial brownout (a “gray failure”), health checks flap — green, red, green — and traffic thrashes between Regions, sometimes making things worse. Route 53 ARC replaces hope with control. It has three parts:

Finally, ARC also provides zonal shift and zonal autoshift — but note the scope difference: zonal shift moves traffic away from a single impaired Availability Zone (for ALB/NLB and some other resources) within a Region. It is an AZ-level tool, not a Region-level one; it’s how you recover from “one AZ is grey” in seconds without a full regional failover, and zonal autoshift lets AWS trigger it for you when it detects AZ impairment. Reach for zonal shift for AZ problems and routing controls for Region problems — conflating the two is a common mix-up.

Component breakdown

Component Role in this architecture Key configuration choices
Route 53 Global DNS steering + health-based regional withdrawal Latency or geoproximity routing; health checks on a deep /health endpoint (not just TCP); low TTL (30–60s) on the apex; Evaluate Target Health on alias records; optionally Route 53 ARC for orchestrated failover
CloudFront Edge caching, TLS, DDoS surface reduction, sub-DNS origin failover Origin group (primary + failover origin) with failover on 500/502/503/504 and connection errors; cache policies per path; AWS WAF + Shield attached; OAC to lock origins to CloudFront
AWS WAF + Shield L7 filtering, rate-limiting, managed rule sets Rate-based rules per IP; AWS Managed Rules (core, SQLi, known-bad-inputs); Shield Advanced if availability SLAs/penalties justify it
Application Load Balancer Regional ingress to compute One per Region; cross-zone balancing on; deregistration delay tuned; HTTP/2; access logs to S3
ECS Fargate / EKS Stateless compute, identical in both Regions Same image digest per release; min capacity sized to absorb the other Region’s traffic (see N+1 sizing below); per-Region auto scaling
DynamoDB Global Tables Multi-active NoSQL data plane Global Tables v2 (2019.11.21); on-demand or autoscaled capacity; PITR + DynamoDB Streams; design for last-writer-wins; conflict-aware item modelling
Aurora Global Database Relational data with single writer + cross-region read & fast promotion One global cluster; primary + ≥1 secondary Region; storage-level replication; managed planned failover for drills; write forwarding if secondary needs local writes; RPO target ~1s
S3 + Cross-Region Replication Object storage (uploads, exports, static origin) Per-Region buckets with bidirectional or hub-and-spoke CRR; replication metrics; versioning on
AWS Global Accelerator (optional) Anycast IPs + faster failover than DNS for non-cacheable, latency-sensitive APIs Two static anycast IPs; endpoint groups per Region; traffic dials; health checks at the network layer
Secrets Manager / KMS Secrets + encryption, replicated per Region Multi-Region KMS keys; Secrets Manager cross-Region replica secrets; no plaintext secrets in images

A few component choices deserve their why, not just their what:

Why DynamoDB Global Tables and not “Aurora for everything”? Because Aurora has exactly one writer. If you put high-velocity, geographically-distributed writes (sessions, telemetry, idempotency keys) into Aurora, half your users pay a transatlantic write penalty all the time, and a writer-region loss blocks all writes until promotion. DynamoDB lets both Regions write locally and reconciles asynchronously. The price is last-writer-wins semantics with no built-in row-level merge — so you must model data to make concurrent writes either rare or commutative (see the conflict note below).

Why keep Aurora at all, then? Because last-writer-wins is wrong for money and relationships. An order total, a ledger entry, a uniqueness constraint, a multi-row transaction — these need a single source of truth and serialisable behaviour. Aurora Global Database gives you that plus a secondary Region that is already warm, replicated at the storage layer with typically sub-second lag, and promotable in about a minute. You get strong consistency where it matters and you concede that a small slice of your system has a real (but fast and well-tooled) failover.

The conflict reality for DynamoDB Global Tables: replication is last-writer-wins based on the most recent write timestamp. If the same item is updated in two Regions within the replication window, one update silently wins. Mitigations that actually work in practice:

Data, replication, and the consistency problem

Compute is the easy half of multi-region: stateless containers deployed from one image to two Regions barely care where they run. Data is the hard half, because physics says a write in Ireland cannot be instantly true in Virginia — there is an ocean and ~70–90ms of round-trip in the way. Every multi-region design is, underneath, a set of decisions about how stale is each piece of data allowed to be, and who wins when two Regions disagree. This section is the deep version of the “data split” the component table introduced.

The three replication mechanics you’ll actually use

S3 Cross-Region Replication (CRR). Objects are replicated asynchronously to a bucket in another Region. Rules to internalise:

DynamoDB Global Tables — multi-active, last-writer-wins. This is the star of active-active because it removes failover entirely for key-addressable data. Mechanics:

Aurora Global Database — one writer, storage-level replication, fast promotion. For relational data that must be strongly consistent:

RDS cross-region read replicas — the non-Aurora option (know it exists). If you’re on plain RDS (MySQL/PostgreSQL/MariaDB) rather than Aurora, you can create a cross-Region read replica. It replicates asynchronously via the engine’s binlog/WAL, so lag is higher and more variable than Aurora’s storage-level replication (seconds to minutes under load), and promotion to a standalone writer is a manual step. It’s a legitimate DR-lite and read-scaling tool, but for serious multi-region relational HA, Aurora Global Database is the stronger primitive — mention this so you’re not surprised when a plain-RDS workload can’t hit the same RPO.

Idempotency — the concept that makes failover safe

Here is the scenario that idempotency exists for: a client sends “authorise $50.” The network hiccups mid-failover, the client retries, and now two requests reach your system — possibly one in each Region. Without protection, you charge $100. Distributed systems deliver at-least-once, so retries are not an edge case; they are guaranteed.

The fix is an idempotency key: the client generates a unique key per logical operation and sends it with every retry. The server records “I have already processed key K” and, on seeing K again, returns the original result instead of re-doing the work. DynamoDB Global Tables is the natural home for these keys — and the correct primitive is a conditional write so the first writer wins the slot atomically:

# First attempt claims the idempotency key ONLY if it doesn't already exist.
aws dynamodb put-item \
  --table-name idempotency \
  --item '{"pk":{"S":"idem#<client-key>"},"status":{"S":"IN_PROGRESS"},"ttl":{"N":"1735689600"}}' \
  --condition-expression "attribute_not_exists(pk)"
# If this SUCCEEDS  -> we are the first; do the real work, then update status=DONE + store the result.
# If this FAILS with ConditionalCheckFailedException -> a duplicate; read the item and
#   return the stored result (or "in progress"), do NOT perform the side effect again.

Two caveats for multi-region specifically: (1) because Global Tables is LWW-eventual, a retry that lands in the other Region within the ~1–2s replication window can both see “no existing key” and both proceed — so keep the side effect itself idempotent at the resource level (e.g. the payment processor also dedupes on the key), and prefer routing a given operation to one Region where you can. (2) Use a TTL attribute so old keys self-expire and don’t grow the table forever.

Split-brain — two writers, one disaster

Split-brain is the nightmare of active-active relational data: a network partition or a botched failover leaves both Regions believing they are the writer, each accepting conflicting writes. When the partition heals, you have two divergent sources of truth and no automatic, correct way to merge them — for money or inventory, that’s data corruption, not a merge conflict.

How the design avoids it:

The mental model to carry: eventual consistency is a feature you opt into where it’s cheap and correct (sessions, carts, idempotency keys), and refuse where it’s a bug (money, inventory, uniqueness). The entire architecture is organised around that one line.

Implementation guidance

Infrastructure as Code. Treat the two Regions as one logical system expressed in Terraform, not two copies maintained by hand. The clean structure:

Terraform shape for the data tier (illustrative):

resource "aws_rds_global_cluster" "this" {
  global_cluster_identifier = "ex-global"
  engine                    = "aurora-postgresql"
  engine_version            = "16.4"
  database_name             = "appdb"
  storage_encrypted         = true
}

# Primary writer — us-east-1
resource "aws_rds_cluster" "primary" {
  provider                  = aws.use1
  cluster_identifier        = "ex-use1"
  engine                    = aws_rds_global_cluster.this.engine
  engine_version            = aws_rds_global_cluster.this.engine_version
  global_cluster_identifier = aws_rds_global_cluster.this.id
  master_username           = var.db_user
  manage_master_user_password = true          # password lives in Secrets Manager, not state
  kms_key_id                = aws_kms_key.use1.arn
  db_subnet_group_name      = module.region_use1.db_subnet_group
}

# Secondary reader (promotable) — eu-west-1
resource "aws_rds_cluster" "secondary" {
  provider                  = aws.euw1
  cluster_identifier        = "ex-euw1"
  engine                    = aws_rds_global_cluster.this.engine
  engine_version            = aws_rds_global_cluster.this.engine_version
  global_cluster_identifier = aws_rds_global_cluster.this.id
  kms_key_id                = aws_kms_key.euw1.arn
  db_subnet_group_name      = module.region_euw1.db_subnet_group
  depends_on                = [aws_rds_cluster.primary]
}

And the DynamoDB global table as a single resource with replicas:

resource "aws_dynamodb_table" "sessions" {
  name             = "sessions"
  billing_mode     = "PAY_PER_REQUEST"
  hash_key         = "pk"
  stream_enabled   = true
  stream_view_type = "NEW_AND_OLD_IMAGES"

  attribute { name = "pk"  type = "S" }

  point_in_time_recovery { enabled = true }

  replica { region_name = "us-east-1" }
  replica { region_name = "eu-west-1" }
}

Networking. Each Region gets its own VPC; do not rely on a cross-Region VPC peering hot path for the request flow — keep request handling entirely in-Region and let only data replication cross Regions (over the AWS backbone, which Aurora and DynamoDB use natively). If services in one Region must reach the other (e.g. Aurora write-forwarding, or an in-Region app reaching the remote writer during a forwarding window), use Transit Gateway with inter-region peering or PrivateLink, and budget for the inter-Region data-transfer cost. Put VPC endpoints (Gateway endpoint for DynamoDB and S3; Interface endpoints for everything else) in each Region so data-plane traffic to DynamoDB/S3 stays off NAT and off the internet.

Identity and access. One AWS Organization, with the workload spread across accounts by environment (and optionally by Region for blast-radius isolation). Use IAM Identity Center for human SSO. For the application: per-Region IAM roles assumed by the Fargate/EKS tasks (IRSA on EKS), scoped to that Region’s table/cluster ARNs. KMS keys are multi-Region so an encrypted DynamoDB/S3 item replicated to the other Region decrypts under the local key replica without a cross-Region KMS call. Secrets Manager uses replica secrets so each Region reads its DB credentials locally — never a cross-Region Secrets Manager dependency on the hot path.

Deployment. One CI/CD pipeline (CodePipeline or GitHub Actions) builds one image, pushes to ECR with cross-Region replication enabled, and deploys the same digest to both Regions — ideally one Region at a time (canary in Region A, bake, then Region B) so a bad release cannot brick both Regions simultaneously. This staggering is itself a resilience control: your two Regions are also two blast-radius boundaries for deploys.

Schema migrations are the sharp edge of single-writer relational data: run them against the Aurora primary writer only, design them backward-compatible (expand/contract), and never ship an app version that requires a schema the other Region’s in-flight traffic hasn’t seen — because after a failover the other Region becomes the writer.

Enterprise considerations

Security and Zero Trust. The perimeter is CloudFront + WAF + Shield, but trust is enforced per request, per Region. Lock origins with Origin Access Control so the ALB/S3 only accept traffic from your CloudFront distribution. Terminate TLS everywhere (ACM), encrypt at rest with multi-Region KMS keys, and keep all secrets in Secrets Manager with per-Region replicas. Apply least-privilege IAM scoped to in-Region resource ARNs — a compromised task in Ireland should have no standing path to Virginia’s data plane beyond what replication already provides. Enable GuardDuty, Security Hub, and CloudTrail (organization trail, multi-Region) so detection is symmetric; a threat actor will not politely confine themselves to your primary Region.

Cost optimization. Active-active is not free; the discipline is spending it where it buys availability and trimming where it does not.

Scalability. DynamoDB on-demand scales horizontally per Region with no capacity planning; Aurora scales reads via in-Region replicas and the cross-Region secondary, but writes remain bound to one node in one Region — that is the architecture’s scaling ceiling for relational data. If relational write throughput becomes the limit, the answer is not a second Aurora writer (multi-writer Aurora is a niche, contention-prone mode) but moving more of the write path onto DynamoDB or sharding the relational domain by tenant/Region.

Reliability and DR (RTO/RPO). This is the headline:

Tier RPO (data loss) RTO (time to recover) Mechanism
DynamoDB Global Tables ~0 for the writing Region; in-flight cross-Region writes within the ~1–2s replication window may be reordered/LWW ~0 (both Regions already active) Native multi-active replication; no failover needed
Aurora — planned failover (drills, maintenance) ~0 (replication caught up before switch) ~1 minute Managed planned failover of the Global Database
Aurora — unplanned failover (region lost) typically < 1 second of lag at the moment of loss a few minutes to promote secondary + repoint app Promote secondary to standalone writer; app uses Region-local writer endpoint
Edge / routing n/a DNS: 30–60s for new resolvers (TTL); CloudFront origin failover and Global Accelerator: seconds for in-flight Route 53 health checks; CloudFront origin group; optional Global Accelerator

The crucial practice: exercise the Aurora planned failover on a schedule (monthly/quarterly) using GameDays. A failover path you have never run is an RTO you cannot honestly claim. Route 53 Application Recovery Controller (ARC) is worth adopting for the orchestration — its routing controls let you flip Region traffic deterministically (and its readiness checks continuously verify the standby is actually able to take load), avoiding the trap of DNS health checks misbehaving during a partial brownout.

Observability. Metrics, logs, and traces are emitted per Region (CloudWatch, X-Ray / OpenTelemetry) and aggregated into a single pane — either CloudWatch cross-account/cross-Region dashboards or a third-party (Datadog/Grafana). The few signals you must watch specifically for this pattern: DynamoDB ReplicationLatency per replica, Aurora AuroraGlobalDBReplicationLag / AuroraGlobalDBRPOLag, Route 53 health-check status, and CloudFront origin failover counts. A rising Aurora replication lag is a leading indicator that your RPO promise is degrading before any outage happens.

Governance. Enforce with Service Control Policies (e.g. deny creation of resources outside your two sanctioned Regions to prevent shadow expansion), AWS Config conformance packs checking that tables are Global Tables and clusters belong to the global cluster, and tagging that distinguishes data-residency classes so a future engineer cannot accidentally replicate EU-resident personal data into a non-permitted Region — a real compliance trap in any cross-Region design.

Reference enterprise example

Helios Pay, a fictional B2B payments platform, processes virtual-card authorisations for ~600 business customers across the EU and US. Their single-region (us-east-1) stack had a 99.9% SLA; a new anchor customer — a logistics firm doing ~$90M/year through Helios — made 99.99% and “no single-region dependency” contractual, with a 0.1% monthly fee credit per 0.1% under SLA. Downtime was now line-itemed.

What they decided:

Numbers:

The test that mattered: in their first quarterly GameDay they ran a managed planned Aurora failover from us-east-1 to eu-west-1 during a low-traffic window. New writes were serving from Ireland in ~70 seconds; DynamoDB needed no action; Global Accelerator shifted the auth API in ~8 seconds; the dashboard followed via Route 53 within a minute. Idempotency keys in DynamoDB meant a handful of authorisation requests retried across the switch did not double-charge — the single most important correctness outcome of the whole exercise.

One scar they earned: their first design put per-merchant rate-limit counters as in-place UPDATEs on a single DynamoDB item, and under concurrent US+EU traffic the LWW replication undercounted — a merchant briefly exceeded their cap. The fix was a per-Region counter item (pk = merchant#REGION) summed at read time. It is the canonical Global Tables lesson: model for last-writer-wins, or it will model you.

When to use it

Use active-active multi-region when:

Think hard (and probably choose simpler) when:

Anti-patterns to avoid:

Alternatives worth weighing:

The honest framing for any architecture review: active-active multi-region is the right answer to a specific and expensive problem, and an expensive mistake when adopted for prestige. Decide which one you have before you double your bill.

Going deeper

Everything so far assumed the happy path works if you wire the services together. The senior-engineer reality is that multi-region breaks on the things between and underneath the services — regional quirks, global control planes, quotas, and failure modes that never show up in a diagram. This is the section that separates “architected on a slide” from “survives a real Region event.”

Regional service availability is not uniform

A Region is not a photocopy of another Region. Not every service, feature, instance type, or model is available in every Region, and new capabilities frequently launch in us-east-1 / us-west-2 first. Before you commit to a second Region, verify that every dependency your workload uses actually exists there — an exotic EC2 instance family, a specific Bedrock model, a Kinesis feature, a particular ACM/KMS capability. The failure mode is nasty: it works in dev (both in us-east-1) and only surfaces when your “identical” eu-west-1 stack can’t provision something. Also check data-residency: replicating EU-resident personal data into a non-permitted Region is a compliance incident, not just a bug — tag data by residency class and use SCPs to fence Regions (as the Governance section notes).

The IAM and “global service” reality

This is the subtlest trap in multi-region. Several services you think of as living “everywhere” actually have a home in us-east-1:

The uncomfortable consequence: a severe us-east-1 event can degrade “global” control planes even when your data plane runs elsewhere. The defence is to remove control-plane calls from the hot path: pre-create all roles, policies, and keys (don’t create IAM roles during a failover), cache credentials, use regional STS, and make sure your failover runbook needs no new API calls to a possibly-impaired global service. Route 53 ARC’s five-Region control plane exists precisely because you may need to act while a control plane is down.

Multi-Region KMS keys — or a cross-Region dependency in disguise

If a DynamoDB item or S3 object is encrypted in Ireland and replicated to Virginia, Virginia must be able to decrypt it locally. With ordinary single-Region KMS keys, that forces a cross-Region KMS call on the hot path — a hidden dependency that couples your “independent” Regions. Multi-Region KMS keys solve this: a primary key and its replica keys share the same key material, so ciphertext produced in one Region decrypts under the local replica in the other, no cross-Region call. Use them for anything that replicates. The multi-Region KMS keys lesson covers the envelope-encryption details; the multi-region rule is simply: replicated data → multi-Region key.

Service Quotas: the classic failed-failover cause

Service Quotas (limits) are per-Region. Your surviving Region must be able to run at ~2× its normal load during a failover — but if you only ever raised quotas (EC2 vCPUs, ENIs, Lambda concurrency, NAT gateways, ELB targets) in the busy Region, the standby will throttle the moment it absorbs the extra traffic, and your beautiful failover stalls at 50% capacity. Raise quotas in both Regions ahead of time, and let ARC readiness checks verify them continuously. This is one of the top real-world reasons a “tested” failover still fails.

Cost math — where active-active flips from asset to liability

Two numbers dominate the multi-region premium, and both are cross-Region data transfer, not compute:

A worked sanity check: if a chunk of “global” data is actually only ever used in one Region (regional analytics staging, per-Region logs), replicating it is pure waste — keep it in a single-Region table/bucket. The discipline that keeps active-active affordable is “replicate the least data that correctness requires,” and pushing cacheable reads to CloudFront so they’re served at edge pricing instead of origin compute + cross-Region fetch. The reference example’s ~1.93× multiplier is typical; teams that replicate everything indiscriminately see 2.5×+ with no availability benefit for the extra spend.

Failure modes the diagram doesn’t show

Cell-based architecture — the next level of blast-radius control

Beyond two Regions, mature teams partition within a Region into cells — independent, fixed-size stacks each serving a subset of customers, with shuffle sharding so any one cell’s failure affects only its slice and no two customers share the exact same set of cells. Multi-region is blast-radius control at the Region level; cell-based architecture is the same idea one level down. It’s how the largest AWS-native services keep a bad deploy or a poison-pill customer from taking down everyone — and it’s the direction to grow once two Regions isn’t fine-grained enough.

Practice challenges

Work these top-to-bottom; they escalate from “read the spectrum” to “prevent a split-brain.” Try each before opening the solution.

1. (Beginner) Pick the DR strategy. An internal reporting tool can be down for up to 4 hours and can lose up to a day of data. The team wants the cheapest option. Which of the four DR strategies fits, and what AWS mechanics implement it?

<details><summary>Solution</summary>

Backup & restore. RTO of hours and RPO of ~a day is exactly its envelope, and it’s the cheapest because nothing runs in the second Region. Implement with cross-Region S3 CRR for data/exports, RDS snapshot copy and AMI/EBS snapshot copy to the DR Region, and IaC (Terraform/CloudFormation) to rebuild the environment on demand.

Why: match the strategy to the required RTO/RPO — paying for warm standby here would waste money buying recovery speed the business doesn’t need. </details>

2. (Beginner) Route 53 for an active-passive tier. You have a primary Region and a warm-standby secondary. Which routing policy and which health-check settings send users to the primary while it’s healthy and to the standby when it isn’t?

<details><summary>Solution</summary>

Use a failover routing policy: a primary record (the primary Region’s ALB/CloudFront) and a secondary record. Attach a Route 53 health check pointed at a deep /health endpoint on the primary, and on alias records set Evaluate Target Health = true. Keep the record TTL low (30–60s) — but expect a multi-minute tail from resolver caching.

Why: failover routing is purpose-built for primary/secondary, and a deep health check (not TCP-only) is what stops Route 53 from routing to a Region whose backend is actually broken. </details>

3. (Intermediate) Fix a Global Tables counter. A per-merchant rate-limit counter is stored as an in-place UPDATE on a single DynamoDB item. Under simultaneous US+EU traffic it undercounts and a merchant exceeds their cap. Redesign it.

<details><summary>Solution</summary>

Split the counter per Region: key it pk = merchant#<REGION> and have each Region increment only its own item with an atomic ADD. Compute the effective count by summing both items at read time. (Alternatively move the counter to a model that tolerates merge.)

Why: Global Tables replication is last-writer-wins — two concurrent in-place updates to the same item silently drop one. Per-Region items never collide, so no update is lost; you trade a slightly more expensive read for a correct count. </details>

4. (Intermediate) Add a third Region. Extend the Terraform so a third Region (say ap-southeast-2) becomes (a) a DynamoDB Global Tables replica and (b) an Aurora Global Database secondary. Sketch the changes.

<details><summary>Solution</summary>

DynamoDB — add one replica block to the existing table resource:

replica { region_name = "ap-southeast-2" }

Aurora — add a new secondary cluster referencing the same global cluster (with an aws.apse2 provider alias and a regional KMS key), mirroring the existing secondary:

resource "aws_rds_cluster" "tertiary" {
  provider                  = aws.apse2
  cluster_identifier        = "ex-apse2"
  engine                    = aws_rds_global_cluster.this.engine
  engine_version            = aws_rds_global_cluster.this.engine_version
  global_cluster_identifier = aws_rds_global_cluster.this.id
  kms_key_id                = aws_kms_key.apse2.arn
  db_subnet_group_name      = module.region_apse2.db_subnet_group
  depends_on                = [aws_rds_cluster.primary]
}

Also raise Service Quotas in the new Region and add its Route 53 record + health check.

Why: Global Tables v2 models replicas as blocks on the one table resource, and an Aurora global cluster takes multiple secondaries — the pattern scales by repetition, but each new Region also needs quotas and routing, which the IaC won’t remind you about. </details>

5. (Advanced) Idempotent authorisation across a failover. Design the write flow so that a client retrying a payment authorisation across a Region failover cannot double-charge.

<details><summary>Solution</summary>

Client sends a unique idempotency key with every attempt (including retries). Server does a conditional PutItem into a DynamoDB (Global Tables) idempotency table with condition-expression "attribute_not_exists(pk)". If it succeeds, this is the first attempt — perform the authorisation, then store the result and mark status=DONE. If it fails with ConditionalCheckFailedException, it’s a duplicate — return the stored result and do not re-authorise. Add a TTL so keys expire, and ensure the downstream processor also dedupes on the key (LWW means two near-simultaneous cross-Region attempts could both see “no key”).

Why: distributed systems are at-least-once, so retries are guaranteed; the conditional write makes “claim the key” atomic so exactly one attempt does the side effect, and belt-and-suspenders dedup downstream covers the sub-2s replication window. </details>

6. (Advanced) Prevent split-brain during a brownout. us-east-1 (the Aurora writer) goes gray — 50% slow, intermittently erroring, not cleanly dead. How do you fail the writer to eu-west-1 without ending up with two writers?

<details><summary>Solution</summary>

Do not auto-promote on a flapping health check. Use Route 53 ARC routing controls with a gating safety rule that makes the “active writer” flag mutually exclusive — it can be on for exactly one Region, so you physically cannot enable eu-west-1 as writer without disabling us-east-1. Trigger the flip deliberately (operator/runbook) via ARC’s five-Region control plane, use Aurora managed failover to promote, and fence the old writer (revoke endpoint/credentials or bump a fencing epoch) so stragglers are rejected. DynamoDB needs no action (already multi-active).

Why: the split-brain risk lives entirely in the single-writer tier and only during promotion; a gating rule plus a deliberate, fenced switch guarantees one-writer-at-a-time even when the signal is ambiguous — which is exactly when naive automation causes two writers. </details>

Common beginner mistakes

Glossary

AWSArchitectureEnterpriseReference Architecture
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments