Work from failure domains and RTO/RPO to concrete designs: zone-spread stateless tiers, data replication options, active-passive and active-active regions, landing zones, hub-and-spoke networks, egress cost, and game days.
Cloud resilience is a set of priced decisions. Each extra nine of availability costs money, adds complexity, and usually forces a consistency compromise. This guide works from failure domains and recovery targets to the concrete patterns that meet them: stateless tiers across zones, data replication choices, active-passive and active-active regions, landing zones that limit blast radius, hub-and-spoke networks, event-driven decoupling, identity, cost, and the game days that prove any of it works. AWS, Azure, and Google Cloud terms appear where they help.
A failure domain is a set of resources that can fail together. Redundancy only helps if the copies sit in different domains, so the first design step is to list the domains and decide which ones the system must survive.
| Domain | Typical causes | Provider constructs | Mitigation |
|---|---|---|---|
| Process or host | Crash, memory leak, disk loss | Instance, VM, pod | Multiple instances, health checks |
| Zone | Data centre power, cooling, network | Availability Zones (AWS, Azure), zones (GCP) | Run across at least two, preferably three |
| Region | Regional control plane, wide network event, disaster | Region; Azure region pairs; GCP multi-region storage | Second region, tested failover |
| Account or tenant | Credential compromise, quota exhaustion, billing suspension | Account, subscription, project | Isolation, separate backup account |
| Change | Bad deploy, bad config, expired certificate | Pipelines, feature flags | Staged rollout, per-zone and per-region deploys |
| Provider | Global service outage | IAM, DNS, global control planes | Static stability; multi-cloud only with a clear reason |
The change row is often the most important: a bad config shipped to every region at once defeats all geographic redundancy. Multi-region only helps if changes roll out one region at a time.
Provider models also differ in ways that affect design. An AWS subnet belongs to one Availability Zone, while Azure and GCP subnets span zones within a region. An AWS or Azure virtual network is regional, whereas a GCP VPC is global with regional subnets. AWS zone names can map to different physical zones per account, so correlate failures using zone IDs.
The data plane serves running traffic. The control plane creates, changes, and scales resources, and during a large event it is often what degrades, just as every customer tries to launch replacement capacity. A statically stable design keeps working without any control plane call: capacity already runs in the surviving zones. "Autoscaling will replace the lost zone" is a bet on the control plane at the worst possible time.
Recovery Time Objective (RTO) is how long a service may be down. Recovery Point Objective (RPO) is how much recent data may be lost. Both must come from the business, per workflow rather than per system.
| Availability | Downtime per year | Downtime per 30.4-day month |
|---|---|---|
| 99.9% | 8.76 hours | 43.8 minutes |
| 99.95% | 4.38 hours | 21.9 minutes |
| 99.99% | 52.6 minutes | 4.38 minutes |
| 99.999% | 5.26 minutes | 26.3 seconds |
Components in series multiply. A request that needs a load balancer at 99.99%, an application tier at 99.95%, and a database at 99.9% gets:
A = 0.9999 * 0.9995 * 0.999 = 0.99840 (99.84%, about 14 hours per year)
The system is less available than its weakest part, and every synchronous dependency makes it worse. Components in parallel, assuming independence, work the other way:
A = 1 - (1 - a)^n
Two independent zones at 99.5%: 1 - 0.005^2 = 0.999975 (99.9975%)
That result is optimistic, because zones share software, deploy pipelines, and regional services. A simple common-cause adjustment assumes that some fraction of unavailability hits all copies at once. If 10% of each zone's unavailability is shared:
U = 0.10 * 0.005 + (0.90 * 0.005)^2
= 0.0005 + 0.00002025
= 0.00052025
A = 99.948%
The shared failure term is about 25 times larger than the independent term. Adding a zone barely helps; removing shared dependencies does. These percentages are illustrative, so use your own incident data.
Failover time also caps availability. If you expect four failovers a year, each taking 15 minutes, that is 60 minutes of downtime out of 525,600, or a ceiling of about 99.989% before anything else goes wrong.
To survive losing one of n equal zones, each zone needs to carry its share of peak load after the loss. Total provisioned capacity is therefore n / (n - 1) of peak.
| Zones | Capacity vs peak | Max utilisation per zone at peak |
|---|---|---|
| 2 | 200% | 50% |
| 3 | 150% | 67% |
| 4 | 133% | 75% |
Three zones is the common default: it needs half the spare capacity of two, and quorum systems keep a majority after losing one zone. The same formula applies to regions.
A tier is stateless when any instance can serve any request and can be destroyed at any time. Sessions and uploads move to managed stores, and instances are replaced from immutable images rather than patched.
Spread the replicas explicitly, because schedulers do not guarantee it. In Kubernetes:
# Deployment pod template spec
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: myapp
DoNotSchedule preserves balance but leaves pods pending during a zone outage if the surviving zones cannot meet the skew rule. ScheduleAnyway favours running over balance. Add a PodDisruptionBudget too.
Common failure modes:
Light in fibre travels at roughly 200 km per millisecond, so regions 6,000 km apart have a round-trip time of at least 60 ms before any processing. Synchronous replication across that distance adds that time to every commit. No product removes this limit.
| Option | Examples | RPO on failure | Consistency | Main cost or risk |
|---|---|---|---|---|
| Synchronous standby across zones | RDS Multi-AZ, Azure SQL zone redundancy, Cloud SQL HA | Zero within the region | Strong | Added commit latency, standby often idle |
| Asynchronous cross-region replica | Read replicas, Aurora Global Database, geo-replication | Replication lag at failure | Replica reads may be stale | Data loss on unplanned failover |
| Distributed consensus SQL | Spanner, CockroachDB, YugabyteDB | Zero with a surviving quorum | Strong | Write latency to quorum, needs three or more sites |
| Multi-writer with async merge | DynamoDB global tables, Cosmos DB multi-region writes | Small, unreplicated writes may be lost | Eventual in the default mode | Lost updates, application-level conflict design |
| Object storage replication | S3 Cross-Region Replication, GCS dual-region, Azure GRS | Replication lag | Varies by product | Deletes and corruption replicate too |
| Point-in-time backup in another account | Snapshots, logs, vaulted backups | Backup interval | Consistent at restore point | Slow restore, only proves itself when tested |
Replication is not backup. It faithfully copies a DROP TABLE, a bad migration, or a ransomware encryption. Keep immutable, versioned backups in a separate account with separate credentials, whatever replication you run.
Async RPO can be estimated. If a database commits 200 writes per second and replication lags 5 seconds when the region is lost, around 1,000 committed writes are missing. If the old primary returns, those writes must be reconciled against a new primary that has moved on.
A consensus cluster needs a majority. A cluster split evenly over two sites loses quorum when either side is lost. Practical designs use three regions, or two main regions plus a lightweight witness in a third.
Most systems should stay single-region with three zones, plus tested cross-region backups. A second region raises cost and operational burden, and failover mistakes cause outages of their own, so require an explicit business case. The standard patterns:
| Pattern | Second region state | Typical RTO | Typical RPO | Relative cost |
|---|---|---|---|---|
| Backup and restore | Backups only | Hours or more | Backup interval | Lowest |
| Pilot light | Data replicated, compute off or minimal | Tens of minutes | Seconds to minutes | Low |
| Warm standby | Scaled-down full stack, serving health checks | Minutes | Seconds | Medium |
| Active-active, partitioned | Both serve traffic; each user homed to one region | Near zero for most users | Near zero to seconds | High |
| Active-active, global writes | Both accept any write | Near zero | Depends on consistency model | Highest |
One region takes all writes, so consistency stays simple. The hard parts are the failover sequence and keeping the passive region honest, because it drifts if nothing exercises it. Pilot light also needs capacity during a regional event, when everyone else in that geography wants it too.
DNS is the classic failover mechanism. A low TTL shortens the wait, but some clients cache beyond it, and existing connections stay open. Anycast entry points, such as AWS Global Accelerator, GCP global load balancing, or Azure Front Door, shift traffic without relying on client DNS behaviour.
resource "aws_route53_health_check" "primary" {
fqdn = "primary.example.com"
port = 443
type = "HTTPS"
resource_path = "/healthz"
request_interval = 30
failure_threshold = 3
}
resource "aws_route53_record" "primary" {
zone_id = var.zone_id
name = "app.example.com"
type = "CNAME"
ttl = 60
records = ["primary.example.com"]
set_identifier = "primary"
health_check_id = aws_route53_health_check.primary.id
failover_routing_policy {
type = "PRIMARY"
}
}
# A matching SECONDARY record points at secondary.example.com
Build an RTO budget from the actual steps rather than guessing. With the settings above:
| Step | Duration |
|---|---|
| Detection: 3 checks at 30 seconds | 90 seconds |
| Human confirmation, if required | 5 to 15 minutes |
| Fence the old primary, promote the replica | Measure in a drill |
| Scale compute to full size | Measure in a drill |
| DNS TTL expiry for well-behaved clients | 60 seconds |
Automatic data failover needs care. A false positive during a network partition produces two primaries, each accepting writes. Fencing prevents this by making sure the old primary cannot accept writes, for instance by revoking its credentials or using a lease it must renew. Many teams automate traffic shifting but keep database promotion as a human decision, accepting a longer RTO to avoid split-brain.
Failback is often harder than failover: the old primary has diverged and must be rebuilt from the new one. Rehearse this path too.
The CAP theorem says that during a partition a system chooses consistency or availability. PACELC adds the everyday case: without a partition, it still chooses between latency and consistency.
Invariants decide the choice. "Stock must not go below zero" cannot be enforced by two regions accepting writes independently; such operations need a single owner or consensus. Practical designs mix models: a strongly consistent core for orders and money, eventual consistency elsewhere. In two-region active-active, each region must be able to carry 100% of peak.
A landing zone is the pre-built organisational foundation that workloads are deployed into: account structure, guardrails, logging, identity, and network. The account boundary is the strongest isolation unit: it separates permissions, quotas, and most billing, so a compromised credential in one account cannot easily reach another.
| Concept | AWS | Azure | Google Cloud |
|---|---|---|---|
| Top-level container | Organization | Tenant, root management group | Organization |
| Grouping | Organizational Unit | Management group | Folder |
| Workload boundary | Account | Subscription | Project |
| Preventive guardrail | Service Control Policy | Azure Policy (deny) | Organization Policy |
A typical structure:
| Unit | Accounts | Purpose |
|---|---|---|
| Root | Management | Billing and organisation policy only, with no workloads |
| Security | Log archive, security tooling | Immutable audit logs, central detection |
| Infrastructure | Network hub, shared services | Connectivity, DNS, registries |
| Workloads/Prod | One per system or team | Production |
| Workloads/Non-prod | Matching staging and dev | Testing |
| Sandbox | Disposable, with budgets | Experiments, no connectivity to production |
Guardrails should stop things that must never happen, and little else. This AWS SCP restricts regions while allowing global services, and protects audit logging:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyUnapprovedRegions",
"Effect": "Deny",
"NotAction": [
"cloudfront:*",
"iam:*",
"organizations:*",
"route53:*",
"sts:*",
"support:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["eu-west-1", "eu-central-1"]
}
}
},
{
"Sid": "ProtectAuditTrail",
"Effect": "Deny",
"Action": [
"cloudtrail:DeleteTrail",
"cloudtrail:StopLogging",
"organizations:LeaveOrganization"
],
"Resource": "*"
}
]
}
SCPs do not apply to the management account, one more reason it should hold no workloads. Review the NotAction list for each global service you use, and test in a sandbox OU before attaching widely.
Many small accounts mean a small blast radius, but more cross-account networking, permissions, and quotas to monitor. Automate account creation through a vending pipeline that applies baseline logging, budgets, and roles.
Full-mesh peering grows as n(n-1)/2 connections: 20 networks need 190 peerings. Hub and spoke replaces this with a central router: each spoke attaches once, and egress, inspection, and hybrid connectivity live in the hub. The AWS construct is Transit Gateway; Azure offers VNet peering to a hub or Virtual WAN; GCP offers Network Connectivity Center and Shared VPC.
| Network | CIDR (example) | Role | Routes to |
|---|---|---|---|
| Hub, region A | 10.0.0.0/16 | Egress, inspection, VPN and private circuits | All spokes, on-premises, internet |
| Prod spoke | 10.10.0.0/16 | Production workloads | Hub only, by default route |
| Non-prod spoke | 10.20.0.0/16 | Staging and dev | Hub only; denied to prod |
| Hub, region B | 10.128.0.0/16 | Mirror of region A | Peered with hub A |
Plan address space before the first VPC, because overlapping CIDRs block later connectivity. Enforce segmentation with route tables, not only security groups:
resource "aws_ec2_transit_gateway" "hub" {
description = "myproject regional hub"
default_route_table_association = "disable"
default_route_table_propagation = "disable"
}
resource "aws_ec2_transit_gateway_route_table" "spokes" {
transit_gateway_id = aws_ec2_transit_gateway.hub.id
}
resource "aws_ec2_transit_gateway_vpc_attachment" "spoke" {
for_each = var.spokes
transit_gateway_id = aws_ec2_transit_gateway.hub.id
vpc_id = each.value.vpc_id
subnet_ids = each.value.attachment_subnet_ids
transit_gateway_default_route_table_association = false
transit_gateway_default_route_table_propagation = false
}
# Associate each attachment with the spokes route table, then:
resource "aws_ec2_transit_gateway_route" "default_to_inspection" {
destination_cidr_block = "0.0.0.0/0"
transit_gateway_attachment_id = var.inspection_attachment_id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.spokes.id
}
Disabling default association and propagation means nothing can talk until a route is added. Spokes associated with one table have no routes to each other, so all traffic goes through inspection. Attach subnets in every zone used, and enable appliance mode on the inspection attachment so that flows stay symmetric through stateful firewalls.
The hub is itself a shared failure domain. A bad route or firewall rule there affects every spoke, so its changes need the strictest process you have. Build one hub per region, with inspection redundant across zones. Hub traffic carries per-gigabyte processing charges, so prefer private endpoints for object storage and other provider services over a central NAT.
Moving a step behind a queue changes the availability arithmetic: the producer needs only the queue, and the consumer can be down for a while without user impact. Queues (SQS, Service Bus, Pub/Sub), event buses (EventBridge, Event Grid), and partitioned logs (Kafka, Kinesis, Event Hubs) all trade latency for survival.
Delivery is at-least-once in most of these, so consumers must be idempotent. Writing to a database and publishing an event also cannot be atomic. The transactional outbox solves this by storing the event in the same transaction as the data, with a relay that publishes it afterwards. In PostgreSQL:
-- Producer: state change and event commit together
BEGIN;
INSERT INTO orders (id, customer_id, total_cents)
VALUES ($1, $2, $3);
INSERT INTO outbox (id, event_type, payload)
VALUES ($4, 'order.created', $5);
COMMIT;
-- Consumer: skip duplicates inside the same transaction as the side effect
BEGIN;
INSERT INTO processed_events (event_id)
VALUES ($1)
ON CONFLICT (event_id) DO NOTHING;
-- Row count 0 means already processed: roll back and acknowledge.
-- Row count 1 means first delivery: apply the change, then commit.
COMMIT;
Failure modes are different from those of synchronous systems. Backlogs replace errors, so alert on the age of the oldest message, not just queue depth. A backlog drains only at the surplus of consumption over arrival:
backlog = 3,000,000 messages
consume = 2,000 msg/s, arrive = 1,500 msg/s
drain time = 3,000,000 / (2,000 - 1,500) = 6,000 s (100 minutes)
Set visibility timeouts longer than worst-case processing time, or messages get processed twice. Route poison messages to a dead-letter queue with an owner. Rate-limit consumers, because a replayed backlog can overload downstream services. In a regional failover, unconsumed messages stay in the failed region unless replicated, so count them in the RPO.
A compromised administrator credential crosses every zone and region, so identity is the real perimeter.
Restrict the federated role's trust policy on the token's sub claim, matching exactly, for example repo:myproject/app:ref:refs/heads/main. Without that condition, any pipeline on the identity provider could assume the role.
Identity is also an availability dependency. IAM control planes and central SSO services may sit in a single location, so an outage there can stop you logging in during the incident you need to fix. Keep sealed break-glass credentials for each critical account, with hardware MFA and alarms on use. If failover needs an SSO login that depends on the failed region, the runbook has a hidden circular dependency.
Resilience adds cost through spare capacity and data movement, and the latter is often missing from early estimates. Rates differ by provider and region, so model volumes and then apply the current price sheet.
| Driver | How it appears | Mitigation |
|---|---|---|
| Cross-zone traffic | Chatty services, synchronous standbys, replicated logs | Topology-aware routing, compression, fewer hops |
| Inter-region traffic | Replication, cross-region calls | Replicate only what the RPO needs, filter, batch |
| Internet egress | Downloads, APIs, multi-cloud exits | CDN, caching, compression |
| NAT and hub processing | Per-gigabyte charges on central paths | Private endpoints, gateway endpoints for object storage |
| Idle standby | Warm region, N+1 headroom | Scale-to-minimum, right pattern per workload |
With three zones and even load balancing, about two-thirds of service-to-service calls cross a zone boundary. Kubernetes can favour local endpoints with spec.trafficDistribution on a Service, though strict locality can overload a zone with fewer replicas. A log replicated to three brokers across zones carries each byte over zone links at least twice.
replication_volume_month = write_gb_per_day * 30 * (regions - 1)
transfer_cost_month = replication_volume_month * inter_region_rate
standby_cost_month = standby_instances * instance_rate * hours_per_month
owner and cost-center at creation.An untested failover is a hypothesis. Testing finds the missing IAM permission and the expired certificate in the standby region. AWS Fault Injection Service and Azure Chaos Studio inject faults with guardrails; Chaos Toolkit and LitmusChaos are open-source options.
A game day needs a hypothesis, a scope, abort conditions, named roles, and a rollback. Start with announced tests in staging, then move towards production.
| Experiment | Injection | Expected result | Abort if |
|---|---|---|---|
| Zone loss | Block network to one zone's subnets | Traffic shifts; remaining capacity holds peak | Latency budget exhausted |
| Database failover | Forced failover | Reconnect within the measured window; no data loss in region | Clients do not reconnect |
| Region failover | Follow the full runbook | RTO and RPO met | Standby cannot serve |
| Backup restore | Restore to an isolated account | Data validated; time recorded | Not applicable: failure here is a finding |
Track every finding to closure, and record measured RTO and RPO over time. Fix the system, not just the runbook.
Use these during an approved drill, with one engineer running the probe while another injects the fault.
# User's view: one line per second, keep this running through the test
while true; do
curl -s -o /dev/null -m 3 -w "$(date +%T) %{http_code} %{time_total}\n" \
https://app.example.com/healthz
sleep 1
done
# Trigger a Multi-AZ database failover
aws rds reboot-db-instance --db-instance-identifier myproject-prod --force-failover
# Start a prepared fault injection experiment
aws fis start-experiment --experiment-template-id EXT123456789
# Confirm remaining targets are healthy
aws elbv2 describe-target-health --target-group-arn "$TARGET_GROUP_ARN"
# Replication lag on a PostgreSQL replica
psql -h replica.example.com -d myproject \
-c "SELECT now() - pg_last_xact_replay_timestamp() AS replica_lag;"
# What are resolvers returning right now?
dig +noall +answer app.example.com
# Pod spread per node and zone
kubectl get pods -l app=myapp -o wide
kubectl get nodes -L topology.kubernetes.io/zone
Checklist for sign-off:
Found a mistake in this tutorial? Let us know and we will fix it.