A single-region Kubernetes cluster is a liability in disguise. When AWS experiences a regional outage, and every major cloud provider does periodically, your entire application goes with it. For systems where availability is a business-critical requirement, a single region is not a deployment strategy, it's a single point of failure at planetary scale.
Multi-region EKS distributes your workloads across two or more AWS regions, giving you geographic redundancy, reduced latency for globally distributed users, and the ability to survive a regional failure without a production incident. But the operational complexity is real, managing two clusters, synchronizing deployments, routing traffic intelligently, and designing for state consistency across regions requires deliberate architecture.
Terraform makes this complexity manageable. With its provider aliasing, module composition, and remote state capabilities, you can define your entire multi-region infrastructure as version-controlled, reviewable, reproducible code.
This guide covers how to architect and implement a multi-region EKS cluster with Terraform, from VPC design and cluster provisioning to cross-region traffic routing, state management, and disaster recovery patterns.
Architectural Overview
Before writing configuration, establish the target architecture. A production multi-region EKS setup typically follows one of two patterns:
Active-Active: Both regions serve production traffic simultaneously. Traffic is load-balanced across regions via a global load balancer (AWS Global Accelerator or Route 53 latency routing). Each region is a full deployment of your application stack. This maximizes availability and minimizes latency but requires careful state synchronization between regions.
Active-Passive (Warm Standby): One region handles all production traffic, the second region runs a scaled-down but fully deployed replica, ready to receive traffic within minutes of a failover. Simpler to operate than active-active, at the cost of idle capacity in the passive region.
For this guide, we'll implement active-passive with warm standby, the more achievable starting point for most teams, with a clear upgrade path to active-active.
┌─────────────────────────────────────────────────────────────┐
│ Route 53 / Global Accelerator │
│ (Primary: eu-east-1 | Failover: eu-west-1) │
└────────────────────┬───────────────────────────┬────────────┘
│ │
┌──────────▼──────────┐ ┌──────────▼──────────┐
│ EKS Cluster │ │ EKS Cluster │
│ eu-east-1 │ │ eu-west-1 │
│ (PRIMARY) │ │ (WARM STANDBY) │
│ │ │ │
│ ┌───────────────┐ │ │ ┌───────────────┐ │
│ │ App Workloads │ │ │ │ App Workloads │ │
│ │ (Full Scale) │ │ │ │ (Min Scale) │ │
│ └───────────────┘ │ │ └───────────────┘ │
│ ┌───────────────┐ │ │ ┌───────────────┐ │
│ │ RDS Primary │◄─┼────┼──│ RDS Replica │ │
│ └───────────────┘ │ │ └───────────────┘ │
└─────────────────────┘ └─────────────────────┘
Step 1 - Repository and Terraform Structure
Organize your Terraform repository to support multi-region deployments without duplication:
/infrastructure
/modules
/vpc ← reusable VPC module
/eks ← reusable EKS module
/rds ← reusable RDS module
/alb ← reusable ALB module
/regions
/eu-east-1 ← primary region configuration
main.tf
variables.tf
outputs.tf
backend.tf
/eu-west-1 ← standby region configuration
main.tf
variables.tf
outputs.tf
backend.tf
/global ← cross-region resources (Route 53, Global Accelerator, IAM)
main.tf
variables.tf
outputs.tf
Each region directory consumes shared modules with region-specific variable overrides. The global directory manages resources that span regions and depends on outputs from both regional deployments.
Step 2 - Configure Terraform Providers with Aliases
Multi-region Terraform requires provider aliasing, defining multiple AWS provider configurations, one per region:
# infrastructure/global/providers.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.0"
}
helm = {
source = "hashicorp/helm"
version = "~> 2.0"
}
}
}
hcl
infrastructure/regions/eu-east-1/providers.tf
provider "aws" {
alias = "primary"
region = "eu-east-1"
default_tags {
tags = {
ManagedBy = "terraform"
Environment = var.environment
Region = "eu-east-1"
Role = "primary"
}
}
}
hcl
infrastructure/regions/eu-west-1/providers.tf
provider "aws" {
alias = "standby"
region = "eu-west-1"
default_tags {
tags = {
ManagedBy = "terraform"
Environment = var.environment
Region = "eu-west-1"
Role = "standby"
}
}
}
hcl
The default_tags block applies consistent tagging to every resource in each region, critical for cost attribution and operational clarity when managing multi-region infrastructure.
Step 3 - VPC Module Design
The VPC module provisions the network foundation. Both regions use the same module with non-overlapping CIDR ranges, a requirement for VPC peering or Transit Gateway connectivity:
# infrastructure/modules/vpc/main.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "${var.cluster_name}-vpc"
cidr = var.vpc_cidr
azs = var.availability_zones
private_subnets = var.private_subnet_cidrs
public_subnets = var.public_subnet_cidrs
enable_nat_gateway = true
single_nat_gateway = false # one NAT gateway per AZ for HA
enable_dns_hostnames = true
enable_dns_support = true
# Required tags for EKS to discover subnets
private_subnet_tags = {
"kubernetes.io/role/internal-elb" = "1"
"kubernetes.io/cluster/${var.cluster_name}" = "owned"
}
public_subnet_tags = {
"kubernetes.io/role/elb" = "1"
"kubernetes.io/cluster/${var.cluster_name}" = "owned"
}
}
# infrastructure/regions/eu-east-1/main.tf
module "vpc_primary" {
source = "../../modules/vpc"
providers = { aws = aws.primary }
cluster_name = "prod-eu-east-1"
vpc_cidr = "10.0.0.0/16" # primary CIDR range
availability_zones = ["eu-east-1a", "eu-east-1b", "eu-east-1c"]
private_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnet_cidrs = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
}
# infrastructure/regions/eu-west-1/main.tf
module "vpc_standby" {
source = "../../modules/vpc"
providers = { aws = aws.standby }
cluster_name = "prod-eu-west-1"
vpc_cidr = "10.1.0.0/16" # non-overlapping CIDR range
availability_zones = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
private_subnet_cidrs = ["10.1.1.0/24", "10.1.2.0/24", "10.1.3.0/24"]
public_subnet_cidrs = ["10.1.101.0/24", "10.1.102.0/24", "10.1.103.0/24"]
}
Non-overlapping CIDRs (10.0.x.x for primary, 10.1.x.x for standby) are mandatory. Overlapping ranges prevent VPC peering and Transit Gateway routing between regions.
Step 4 - EKS Cluster Module
The EKS module provisions the control plane and managed node groups:
# infrastructure/modules/eks/main.tf
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 20.0"
cluster_name = var.cluster_name
cluster_version = var.kubernetes_version
vpc_id = var.vpc_id
subnet_ids = var.private_subnet_ids
cluster_endpoint_public_access = false # private endpoint only
cluster_endpoint_private_access = true
cluster_addons = {
coredns = { most_recent = true }
kube-proxy = { most_recent = true }
vpc-cni = { most_recent = true }
aws-ebs-csi-driver = { most_recent = true }
}
eks_managed_node_groups = {
system = {
name = "${var.cluster_name}-system"
instance_types = ["m6i.large"]
min_size = 2
max_size = 4
desired_size = 2
labels = { role = "system" }
taints = [{
key = "CriticalAddonsOnly"
value = "true"
effect = "NO_SCHEDULE"
}]
}
application = {
name = "${var.cluster_name}-application"
instance_types = ["m6i.xlarge", "m6i.2xlarge"]
min_size = var.min_app_nodes
max_size = var.max_app_nodes
desired_size = var.desired_app_nodes
labels = { role = "application" }
}
}
# Enable IRSA for pod-level IAM roles
enable_irsa = true
}
In the standby region, pass reduced node counts to minimize idle capacity costs:
# infrastructure/regions/eu-west-1/main.tf - standby node counts
module "eks_standby" {
source = "../../modules/eks"
providers = { aws = aws.standby }
cluster_name = "prod-eu-west-1"
kubernetes_version = "1.29"
vpc_id = module.vpc_standby.vpc_id
private_subnet_ids = module.vpc_standby.private_subnets
# Warm standby — minimum viable footprint, scales up on failover
min_app_nodes = 1
max_app_nodes = 20 # headroom for failover traffic burst
desired_app_nodes = 2
}
Step 5 - Remote State Configuration and Cross-Region References
Each region's Terraform state is stored independently in S3. The global configuration reads outputs from both regional states:
# infrastructure/regions/eu-east-1/backend.tf
hcl
terraform {
backend "s3" {
bucket = "your-org-terraform-state"
key = "eks/eu-east-1/terraform.tfstate"
region = "eu-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
hcl
infrastructure/global/main.tf - reading cross-region outputs
data "terraform_remote_state" "primary" {
backend = "s3"
config = {
bucket = "your-org-terraform-state"
key = "eks/eu-east-1/terraform.tfstate"
region = "eu-east-1"
}
}
data "terraform_remote_state" "standby" {
backend = "s3"
config = {
bucket = "your-org-terraform-state"
key = "eks/eu-west-1/terraform.tfstate"
region = "eu-east-1" # state bucket is in primary region; key points to standby state
}
}
Remote state data sources allow your global configuration to consume the ALB DNS names, cluster endpoint ARNs, and VPC IDs from both regions, enabling Route 53 health-check-based failover configuration without manual copy-pasting.
## Step 6 - Cross-Region Traffic Routing with Route 53
Configure Route 53 health-check-based failover routing, automatically switching traffic to the standby region when the primary region's health check fails:
hcl
infrastructure/global/route53.tf
Health check against primary region ALB
resource "aws_route53_health_check" "primary" {
fqdn = data.terraform_remote_state.primary.outputs.alb_dns_name
port = 443
type = "HTTPS"
resource_path = "/health"
failure_threshold = 3
request_interval = 30
tags = { Name = "primary-health-check" }
}
Primary record - active under normal conditions
resource "aws_route53_record" "primary" {
zone_id = var.route53_zone_id
name = "api.your-domain.com"
type = "A"
failover_routing_policy {
type = "PRIMARY"
}
set_identifier = "primary"
health_check_id = aws_route53_health_check.primary.id
alias {
name = data.terraform_remote_state.primary.outputs.alb_dns_name
zone_id = data.terraform_remote_state.primary.outputs.alb_zone_id
evaluate_target_health = true
}
}
Standby record - receives traffic when primary health check fails
resource "aws_route53_record" "standby" {
zone_id = var.route53_zone_id
name = "api.your-domain.com"
type = "A"
failover_routing_policy {
type = "SECONDARY"
}
set_identifier = "standby"
alias {
name = data.terraform_remote_state.standby.outputs.alb_dns_name
zone_id = data.terraform_remote_state.standby.outputs.alb_zone_id
evaluate_target_health = true
}
}
With this configuration, Route 53 continuously polls the primary region's health check endpoint. If three consecutive checks fail (90 seconds), DNS failover to the standby region initiates automatically, no human intervention required.
## Step 7 - GitOps Deployment with Flux Across Regions
Managing application deployments across two clusters requires a GitOps controller in each region pulling from the same source of truth. **Flux CD** is purpose-built for this:
yaml
gitops/clusters/eu-east-1/flux-system/kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps
namespace: flux-system
spec:
interval: 5m
path: ./gitops/apps/production
prune: true
sourceRef:
kind: GitRepository
name: fleet-repository
yaml
gitops/clusters/eu-west-1/flux-system/kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps
namespace: flux-system
spec:
interval: 5m
path: ./gitops/apps/production # same app manifests, different cluster
prune: true
sourceRef:
kind: GitRepository
name: fleet-repository
Both Flux instances reconcile against the same `gitops/apps/production` path in your Git repository. A single push to main deploys to both regions, maintaining configuration parity automatically without manual multi-cluster kubectl commands.
## Step 8 - Database Strategy for Multi-Region
Application state is the hardest problem in multi-region architecture. For PostgreSQL:
hcl
infrastructure/modules/rds/main.tf - primary RDS instance
resource "aws_db_instance" "primary" {
provider = aws.primary
identifier = "${var.cluster_name}-primary"
engine = "postgres"
engine_version = "16.1"
instance_class = "db.r6g.xlarge"
allocated_storage = 100
storage_encrypted = true
backup_retention_period = 7
backup_window = "03:00-04:00"
# Enable automated backups to S3 for cross-region restore
enabled_cloudwatch_logs_exports = ["postgresql"]
}
Cross-region read replica in standby region
resource "aws_db_instance" "standby_replica" {
provider = aws.standby
identifier = "${var.cluster_name}-standby-replica"
replicate_source_db = aws_db_instance.primary.arn # cross-region replication
instance_class = "db.r6g.large" # smaller read-only in standby
storage_encrypted = true
# Replica can be promoted to primary during failover
# Promotion is a manual or automated operational step, not instantaneous
}
The read replica in the standby region receives continuous replication from the primary, typically with 1–5 seconds of replication lag. During a failover, promoting the replica to a standalone primary takes several minutes, during which write operations are unavailable. Design your application's failover behavior (queuing writes, surfacing a maintenance page) around this window.
## CI/CD Integration
yaml
.github/workflows/terraform-deploy.yml
name: Terraform Multi-Region Deploy
on:
push:
branches: [main]
paths: ['infrastructure/**']
jobs:
deploy-primary:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Apply - eu-east-1
working-directory: infrastructure/regions/eu-east-1
run: |
terraform init
terraform apply -auto-approve
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
deploy-standby:
runs-on: ubuntu-latest
needs: deploy-primary # apply standby after primary succeeds
environment: production
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Apply — eu-west-1
working-directory: infrastructure/regions/eu-west-1
run: |
terraform init
terraform apply -auto-approve
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
deploy-global:
runs-on: ubuntu-latest
needs: [deploy-primary, deploy-standby] # global depends on both regions
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Apply — global
working-directory: infrastructure/global
run: |
terraform init
terraform apply -auto-approve
The `needs` dependency chain ensures the global configuration (Route 53 failover) is applied only after both regional clusters are provisioned and their outputs are available in remote state.
## Common Pitfalls to Avoid
**Overlapping CIDR ranges.** The most common setup mistake, and the one that forces a complete VPC rebuild to fix. Plan your IP address space before writing any Terraform.
**Single NAT gateway per region.** Using one NAT gateway per region (instead of one per AZ) is cheaper but creates an availability zone as a single point of failure. Use `single_nat_gateway = false` in production.
**Not testing failover.** A failover procedure that has never been practiced will fail under pressure. Schedule a quarterly failover drill, redirect traffic to the standby region, validate application behavior, then cut back.
**Treating the standby as truly passive.** A warm standby that receives no real traffic is also receiving no validation that it works. Consider routing a small percentage of read traffic (5–10%) to the standby region to continuously validate its health.
**State drift between regions.** Without GitOps enforcing parity, application deployments to the primary region can drift from the standby. Flux or ArgoCD reconciliation against a single Git source is the most reliable prevention.
## Conclusion
Building a multi-region EKS cluster with Terraform is one of the highest-leverage investments a platform team can make in system reliability. It eliminates the single largest uncontrolled failure mode in cloud-native infrastructure, regional availability, and does so in a way that is reproducible, version-controlled, and operationally predictable.
Start with active-passive. Get the Terraform modules right, validate the Route 53 failover, practice the database promotion procedure, and establish GitOps parity across both clusters. Once that foundation is solid, the path to active-active is an evolution of what you've already built, not a rewrite.
The blueprint takes time to draw. The construction, as always, is where automation earns its keep.
*Using GKE or AKS instead of EKS? The multi-region architecture patterns and GitOps strategy in this guide apply directly, the Terraform provider and cluster module differ, but the design is cloud-agnostic. Drop your platform in the comments.*
Top comments (0)