DEV Community

Cover image for Beyond the Basics: How to Build a Multi-Region EKS Cluster with Terraform
ThankGod Chibugwum Obobo
ThankGod Chibugwum Obobo

Posted on • Originally published at actocodes.hashnode.dev

Beyond the Basics: How to Build a Multi-Region EKS Cluster with Terraform

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  │  │
          │  └───────────────┘  │    │  └───────────────┘  │
          └─────────────────────┘    └─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}

Enter fullscreen mode Exit fullscreen mode


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"
}
}
}


Enter fullscreen mode Exit fullscreen mode


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"
}
}
}

Enter fullscreen mode Exit fullscreen mode


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"
  }
}
Enter fullscreen mode Exit fullscreen mode
# 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"]
}
Enter fullscreen mode Exit fullscreen mode
# 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"]
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode


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"
}
}


Enter fullscreen mode Exit fullscreen mode


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:
Enter fullscreen mode Exit fullscreen mode


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:

Enter fullscreen mode Exit fullscreen mode


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


Enter fullscreen mode Exit fullscreen mode


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:
Enter fullscreen mode Exit fullscreen mode


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
Enter fullscreen mode Exit fullscreen mode


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 }}
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode



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.*
Enter fullscreen mode Exit fullscreen mode

Top comments (0)