How to Set Up Cloudflare Load Balancers with Geo-Steering Using Terraform

Learn how to configure Cloudflare load balancers with geographic traffic steering using Terraform. This guide covers origin pools, health checks, geo-routing, session affinity, and reusable Terraform modules for multi-region deployments.

If you are running applications across multiple regions and need to route users to the closest backend server, Cloudflare Load Balancers with geo-steering is one of the best solutions available. In this guide, I will walk you through how to set up Cloudflare load balancers using Terraform, configure geographic traffic routing, implement health checks for automatic failover, and package everything into a reusable Terraform module.

This is based on a real-world production setup routing API traffic across EU and US regions.

Table of Contents

What is a Cloudflare Load Balancer?

A Cloudflare Load Balancer is a DNS-based traffic distribution service that sits in front of your origin servers. Unlike traditional load balancers that operate at the infrastructure level (like AWS ALB or Nginx), Cloudflare’s load balancer operates at the DNS and Anycast network level, making routing decisions before traffic even reaches your infrastructure.

When a user makes a request to your domain, Cloudflare’s global network (spanning 300+ data centers) intercepts the request and intelligently routes it to the most appropriate backend server based on rules you define — such as geographic proximity, server health, or latency.

Key characteristics of Cloudflare Load Balancers:

  • DNS-level routing: Routing decisions happen at the edge, before traffic reaches your servers
  • Global Anycast network: Leverages Cloudflare’s 300+ PoPs (Points of Presence) worldwide
  • Built-in health monitoring: Automatically detects unhealthy origins and reroutes traffic
  • Multiple steering policies: Supports geographic, latency-based, random, and proximity-based routing
  • Proxy integration: Works seamlessly with Cloudflare’s CDN, WAF, and DDoS protection

What Problem Does It Solve?

Consider this scenario: You have an API serving users globally. Your servers are deployed in two regions — EU (Ireland, eu-west-1) and US (Oregon, us-west-2). Without a geographic load balancer:

  1. High latency for distant users: A user in Kenya hitting a US-based server experiences 200-400ms of additional latency per request
  2. No automatic failover: If your EU server goes down, EU users get errors until someone manually switches DNS
  3. Uneven load distribution: All traffic hits a single region, leaving the other underutilized
  4. Poor user experience: Slow API responses lead to degraded app performance and user frustration

Cloudflare Load Balancers solve all of these problems by:

  • Routing users to the nearest healthy region — reducing latency
  • Automatically failing over when a region becomes unhealthy — improving reliability
  • Distributing traffic geographically — balancing server load
  • Maintaining session affinity — ensuring consistent user experiences

Key Concepts

Before diving into the implementation, let us understand the key components:

Origins

An origin is a backend server that handles requests. This could be an IP address, a hostname pointing to an AWS ELB, an EKS ingress endpoint, or any server that can respond to HTTP/HTTPS requests.

Pools

A pool is a group of one or more origins, typically in the same geographic region. For example, you might have an “EU Pool” containing your European servers and a “US Pool” containing your American servers. Pools are monitored for health and traffic is routed to healthy pools.

Monitors (Health Checks)

A monitor defines how Cloudflare checks the health of origins within a pool. Monitors send periodic HTTP/HTTPS requests to a specified endpoint (like /health) and mark origins as healthy or unhealthy based on the response.

Steering Policies

A steering policy determines how Cloudflare routes traffic across pools. Options include:

PolicyDescription
geoRoutes based on user’s geographic location (region/country)
dynamic_latencyRoutes based on observed round-trip time
proximity_strictRoutes to the geographically closest pool
randomDistributes traffic randomly across pools
offUses only the default pool order (first healthy pool wins)

Session Affinity

Session affinity ensures that requests from the same user are consistently routed to the same origin server. This is critical for applications that maintain server-side state.

Prerequisites

Before you begin, ensure you have:

  • Terraform >= 1.0 installed (install guide)
  • A Cloudflare account with a domain configured
  • A Cloudflare API token with the following permissions:
    • Zone > Load Balancers > Edit
    • Zone > DNS > Edit
    • Account > Load Balancing: Monitors and Pools > Edit
  • Origin servers deployed in at least two regions with a health check endpoint (e.g., /health)
  • Your Cloudflare Zone ID and Account ID (found in the Cloudflare dashboard under your domain’s overview page)

Creating a Cloudflare API Token

  1. Go to the Cloudflare dashboard
  2. Navigate to My Profile > API Tokens
  3. Click Create Token
  4. Use the Custom Token template
  5. Add the permissions listed above
  6. Restrict to your specific zone for security

Architecture Overview

Here is what we are building:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
                    ┌──────────────────────────────┐
                    │        User Requests          │
                    │   (EU, US, Africa, Asia...)   │
                    └──────────────┬───────────────┘
                    ┌──────────────────────────────┐
                    │   Cloudflare DNS + Proxy      │
                    │  (api-lb.example.com)         │
                    │  Proxied through Cloudflare   │
                    └──────────────┬───────────────┘
                    ┌──────────────────────────────┐
                    │   Cloudflare Load Balancer    │
                    │   Steering Policy: geo        │
                    │   Session Affinity: ip_cookie  │
                    └──────┬───────────────┬───────┘
                           │               │
              ┌────────────┘               └────────────┐
              ▼                                         ▼
    ┌──────────────────┐                    ┌──────────────────┐
    │    EU Pool        │                    │    US Pool        │
    │  WEU + EEU        │                    │  WNAM + ENAM      │
    │  + Kenya (KE)     │                    │  + USA (US)       │
    │                    │                    │                    │
    │  ┌──────────────┐ │                    │  ┌──────────────┐ │
    │  │  EU Origin    │ │                    │  │  US Origin    │ │
    │  │  api-eu.com   │ │                    │  │  api-us.com   │ │
    │  └──────────────┘ │                    │  └──────────────┘ │
    │                    │                    │                    │
    │  Health Monitor    │                    │  Health Monitor    │
    │  GET /health @60s  │                    │  GET /health @60s  │
    └──────────────────┘                    └──────────────────┘

Traffic flow:

  1. A user makes a request to api-lb.example.com
  2. Cloudflare’s Anycast network receives the request at the nearest PoP
  3. The load balancer determines the user’s geographic location
  4. Geo-steering routes the request to the appropriate regional pool
  5. Health monitors ensure only healthy origins receive traffic
  6. Session affinity keeps the user on the same origin for subsequent requests

Project Structure

We will organize our Terraform code using modules for reusability:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
cloudflare/
├── provider.tf                              # Terraform and provider config
├── variables.tf                             # Root-level variables
├── main.tf                                  # Module instantiations
├── outputs.tf                               # Root-level outputs
└── modules/
    └── cloudflare-geo-load-balancer/
        ├── variables.tf                     # Module input variables
        ├── main.tf                          # All resources
        └── outputs.tf                       # Module outputs

This modular approach allows you to reuse the same load balancer configuration across multiple environments (staging, preprod, production) and services.

Step 1: Configure the Terraform Provider

First, set up the Terraform provider configuration. Create provider.tf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
terraform {
  required_version = ">= 1.0"

  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.0"
    }
  }

  # Recommended: Configure remote state for team collaboration
  # backend "s3" {
  #   bucket         = "my-terraform-state"
  #   key            = "cloudflare/terraform.tfstate"
  #   region         = "us-west-2"
  #   encrypt        = true
  #   dynamodb_table = "terraform-state-lock"
  # }
}

provider "cloudflare" {
  api_token = var.cloudflare_api_token
}

We are using the Cloudflare Terraform provider version 4.x. The api_token method is the recommended authentication approach — it is more secure than using a global API key because tokens can be scoped to specific permissions.

Tip: For production deployments, always use a remote backend (like S3 + DynamoDB) to store your Terraform state. This enables team collaboration and state locking to prevent concurrent modifications.

Step 2: Define Root Variables

Create variables.tf at the root level for shared configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
variable "cloudflare_api_token" {
  description = "Cloudflare API token"
  type        = string
  sensitive   = true
}

variable "cloudflare_zone_id" {
  description = "Cloudflare zone ID for your domain"
  type        = string
}

variable "cloudflare_account_id" {
  description = "Cloudflare account ID"
  type        = string
}

The sensitive = true flag on the API token ensures Terraform will never display this value in plan output or logs.

Store these values in a terraform.tfvars file (which should be in your .gitignore):

1
2
3
cloudflare_api_token  = "your-api-token-here"
cloudflare_zone_id    = "your-zone-id-here"
cloudflare_account_id = "your-account-id-here"

Alternatively, you can use environment variables:

1
2
3
export TF_VAR_cloudflare_api_token="your-api-token-here"
export TF_VAR_cloudflare_zone_id="your-zone-id-here"
export TF_VAR_cloudflare_account_id="your-account-id-here"

Step 3: Build the Reusable Terraform Module

Now let us build the core module. Create the module variables in modules/cloudflare-geo-load-balancer/variables.tf:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
variable "account_id" {
  description = "Cloudflare account ID"
  type        = string
}

variable "zone_id" {
  description = "Cloudflare zone ID"
  type        = string
}

variable "name" {
  description = "Name of the load balancer (e.g., 'learn-api')"
  type        = string
}

variable "environment" {
  description = "Environment (e.g., 'staging', 'prod')"
  type        = string
}

variable "hostname" {
  description = "Hostname for the load balancer (e.g., 'api-lb.example.com')"
  type        = string
}

variable "eu_origin" {
  description = "EU origin server address"
  type        = string
}

variable "us_origin" {
  description = "US origin server address"
  type        = string
}

# Health check configuration
variable "health_check_path" {
  description = "Path for health checks"
  type        = string
  default     = "/health"
}

variable "health_check_interval" {
  description = "Interval between health checks in seconds"
  type        = number
  default     = 60
}

variable "health_check_retries" {
  description = "Number of retries for health checks"
  type        = number
  default     = 2
}

variable "health_check_timeout" {
  description = "Timeout for health checks in seconds"
  type        = number
  default     = 5
}

variable "health_check_type" {
  description = "Type of health check (http or https)"
  type        = string
  default     = "https"
}

variable "health_check_expected_codes" {
  description = "Expected HTTP response codes for healthy status"
  type        = string
  default     = "200"
}

variable "health_check_allow_insecure" {
  description = "Allow insecure SSL connections for health checks"
  type        = bool
  default     = false
}

variable "health_check_follow_redirects" {
  description = "Follow redirects during health checks"
  type        = bool
  default     = false
}

# Load balancer configuration
variable "ttl" {
  description = "TTL for the DNS record in seconds"
  type        = number
  default     = 60
}

variable "proxied" {
  description = "Whether to proxy traffic through Cloudflare"
  type        = bool
  default     = true
}

variable "session_affinity" {
  description = "Session affinity method (none, cookie, ip_cookie)"
  type        = string
  default     = "none"
}

variable "steering_policy" {
  description = "Steering policy for the load balancer"
  type        = string
  default     = "geo"

  validation {
    condition = contains(
      ["off", "random", "dynamic_latency", "proximity_strict", "geo"],
      var.steering_policy
    )
    error_message = "Steering policy must be one of: off, random, dynamic_latency, proximity_strict, geo"
  }
}

Notice the validation block on steering_policy — this prevents invalid values from being passed, catching configuration errors early during terraform plan rather than at apply time.

Step 4: Configure Health Check Monitors

Health checks are the foundation of reliable load balancing. They continuously probe your origins to ensure traffic is only sent to healthy servers.

Add the following to modules/cloudflare-geo-load-balancer/main.tf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.0"
    }
  }
}

# Health check monitor for EU origin
resource "cloudflare_load_balancer_monitor" "health_check_eu" {
  account_id       = var.account_id
  type             = var.health_check_type
  port             = var.health_check_type == "https" ? 443 : 80
  path             = var.health_check_path
  interval         = var.health_check_interval
  retries          = var.health_check_retries
  timeout          = var.health_check_timeout
  method           = "GET"
  expected_codes   = var.health_check_expected_codes
  description      = "Health check for ${var.environment}-${var.name} EU origin"
  allow_insecure   = var.health_check_allow_insecure
  follow_redirects = var.health_check_follow_redirects

  header {
    header = "Host"
    values = [var.eu_origin]
  }
}

# Health check monitor for US origin
resource "cloudflare_load_balancer_monitor" "health_check_us" {
  account_id       = var.account_id
  type             = var.health_check_type
  port             = var.health_check_type == "https" ? 443 : 80
  path             = var.health_check_path
  interval         = var.health_check_interval
  retries          = var.health_check_retries
  timeout          = var.health_check_timeout
  method           = "GET"
  expected_codes   = var.health_check_expected_codes
  description      = "Health check for ${var.environment}-${var.name} US origin"
  allow_insecure   = var.health_check_allow_insecure
  follow_redirects = var.health_check_follow_redirects

  header {
    header = "Host"
    values = [var.us_origin]
  }
}

Let us break down the key configuration options:

  • type: The protocol to use — https or http. Always use https in production.
  • port: Dynamically set to 443 for HTTPS or 80 for HTTP using a conditional expression.
  • path: The endpoint to probe. Your application should expose a /health endpoint that returns a 200 status when the server is healthy.
  • interval: How often (in seconds) Cloudflare sends a health check. 60 seconds is a good balance between responsiveness and not overloading your servers.
  • retries: Number of consecutive failures before marking an origin as unhealthy. Setting this to 2 prevents false positives from transient network issues.
  • timeout: How long to wait for a response. 5 seconds is reasonable for most API health endpoints.
  • expected_codes: The HTTP status code that indicates a healthy origin. Use "200" for standard health checks.
  • allow_insecure: Whether to skip SSL certificate verification. Keep this false in production to ensure valid certificates.
  • follow_redirects: Whether to follow HTTP 3xx redirects during health checks. Enable this if your health endpoint redirects (e.g., HTTP to HTTPS redirect).
  • header: The Host header is critical — it must match the origin’s expected hostname for proper routing, especially when origins are behind shared infrastructure like a reverse proxy or ingress controller.

Why Separate Monitors Per Region?

We create separate monitors for EU and US origins rather than sharing one. This is because each monitor sends the appropriate Host header matching its specific origin. If your origins share the same health check path, you might be tempted to reuse a monitor, but the Host header difference makes separate monitors necessary.

Step 5: Create Origin Pools

Pools group your origins by region. Each pool is associated with a health monitor and configured for specific check regions.

Add the following to the same main.tf file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# EU region pool
resource "cloudflare_load_balancer_pool" "eu_pool" {
  account_id         = var.account_id
  name               = "${var.environment}-${var.name}-eu"
  description        = "EU region pool for ${var.environment}-${var.name}"
  enabled            = true
  minimum_origins    = 1
  notification_email = ""
  monitor            = cloudflare_load_balancer_monitor.health_check_eu.id
  check_regions      = ["WEU"]

  origins {
    name    = "${var.environment}-${var.name}-eu-origin"
    address = var.eu_origin
    enabled = true
    weight  = 1
    header {
      header = "Host"
      values = [var.eu_origin]
    }
  }

  origin_steering {
    policy = "random"
  }
}

# US region pool
resource "cloudflare_load_balancer_pool" "us_pool" {
  account_id         = var.account_id
  name               = "${var.environment}-${var.name}-us"
  description        = "US region pool for ${var.environment}-${var.name}"
  enabled            = true
  minimum_origins    = 1
  notification_email = ""
  monitor            = cloudflare_load_balancer_monitor.health_check_us.id
  check_regions      = ["WNAM"]

  origins {
    name    = "${var.environment}-${var.name}-us-origin"
    address = var.us_origin
    enabled = true
    weight  = 1
    header {
      header = "Host"
      values = [var.us_origin]
    }
  }

  origin_steering {
    policy = "random"
  }
}

Let us break down the important pool configuration:

check_regions

This determines where Cloudflare runs health checks from. Cloudflare has health check probes in multiple regions. By setting check_regions = ["WEU"] for the EU pool, health checks are performed from Western Europe data centers — giving you an accurate picture of the pool’s health from the perspective of users in that region.

Available check regions include:

CodeRegion
WEUWestern Europe
EEUEastern Europe
WNAMWestern North America
ENAMEastern North America
WSAMWestern South America
ESAMEastern South America
OCOceania
WASWestern Asia
EASEastern Asia
SASSouthern Asia
SEASSoutheast Asia
NEASNortheast Asia
NAFNorthern Africa
SAFSouthern Africa
MEMiddle East

minimum_origins

The minimum number of healthy origins required for the pool to be considered healthy. With a single origin per pool, set this to 1. If you have multiple origins in a pool, you might set this higher.

origin_steering

Determines how traffic is distributed among origins within a pool. With a single origin this does not matter much, but when you have multiple origins in a pool, random distributes traffic evenly.

The Host Header in Origins

The header block sets the Host header on requests forwarded to the origin. This is essential when your origins are behind shared infrastructure (like an ingress controller or reverse proxy) that routes based on the Host header. Without this, your origin might not know which service to route the request to.

Step 6: Create the Load Balancer with Geo-Steering

Now for the main event — the load balancer itself with geographic steering configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# Load balancer with geo-steering
resource "cloudflare_load_balancer" "main" {
  zone_id          = var.zone_id
  name             = var.hostname
  description      = "Load balancer for ${var.environment}-${var.name}"
  default_pool_ids = [
    cloudflare_load_balancer_pool.eu_pool.id,
    cloudflare_load_balancer_pool.us_pool.id
  ]
  fallback_pool_id = cloudflare_load_balancer_pool.eu_pool.id
  proxied          = var.proxied
  ttl              = var.proxied ? null : var.ttl
  steering_policy  = var.steering_policy
  session_affinity = var.session_affinity

  # --- Geo-steering: Region-level routing ---

  # Western Europe → EU Pool
  region_pools {
    region   = "WEU"
    pool_ids = [cloudflare_load_balancer_pool.eu_pool.id]
  }

  # Eastern Europe → EU Pool
  region_pools {
    region   = "EEU"
    pool_ids = [cloudflare_load_balancer_pool.eu_pool.id]
  }

  # Western North America → US Pool
  region_pools {
    region   = "WNAM"
    pool_ids = [cloudflare_load_balancer_pool.us_pool.id]
  }

  # Eastern North America → US Pool
  region_pools {
    region   = "ENAM"
    pool_ids = [cloudflare_load_balancer_pool.us_pool.id]
  }

  # --- Geo-steering: Country-level routing ---

  # Kenya → EU Pool (closer geographically to EU)
  country_pools {
    country  = "KE"
    pool_ids = [cloudflare_load_balancer_pool.eu_pool.id]
  }

  # United States → US Pool
  country_pools {
    country  = "US"
    pool_ids = [cloudflare_load_balancer_pool.us_pool.id]
  }

  # Location strategy for proximity-based steering
  location_strategy {
    prefer_ecs = "never"
    mode       = "resolver_ip"
  }
}

This is where all the magic happens. Let me explain each section:

default_pool_ids

This defines the ordered list of pools used when no geo-steering rule matches the user’s location. The order matters — the first pool in the list is preferred. For regions not explicitly defined (like Asia or South America), traffic falls through to these default pools.

fallback_pool_id

The pool of last resort. When all pools in a region are unhealthy, traffic is sent to the fallback pool. Choose your most reliable region for this.

proxied

When set to true, traffic flows through Cloudflare’s network, enabling:

  • DDoS protection
  • WAF (Web Application Firewall)
  • CDN caching
  • SSL/TLS termination
  • Bot management

When proxied, the TTL is automatically managed by Cloudflare (hence ttl = var.proxied ? null : var.ttl).

region_pools

Region-level routing rules. These define which pool(s) serve traffic from specific geographic regions. You can specify multiple pools per region for fallback:

1
2
3
4
5
6
7
8
# Primary: EU Pool, Fallback: US Pool
region_pools {
  region   = "WEU"
  pool_ids = [
    cloudflare_load_balancer_pool.eu_pool.id,
    cloudflare_load_balancer_pool.us_pool.id
  ]
}

country_pools

Country-level routing takes highest precedence in geo-steering. Use this for countries that need specific routing, such as routing Kenya (KE) to the EU pool because it is geographically closer to Europe than to the US:

1
2
3
4
country_pools {
  country  = "KE"
  pool_ids = [cloudflare_load_balancer_pool.eu_pool.id]
}

Country codes follow the ISO 3166-1 alpha-2 standard.

location_strategy

Configures how Cloudflare determines the user’s location:

  • mode = "resolver_ip": Uses the IP address of the user’s DNS resolver to determine location. This is the default and works well for most cases.
  • prefer_ecs = "never": Disables EDNS Client Subnet (ECS). ECS can provide more accurate location data but has privacy implications. Set to "always" if you need maximum accuracy and your DNS resolvers support it.

Step 7: Define Module Outputs

Create modules/cloudflare-geo-load-balancer/outputs.tf to expose useful information:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
output "load_balancer_id" {
  description = "ID of the load balancer"
  value       = cloudflare_load_balancer.main.id
}

output "load_balancer_hostname" {
  description = "Hostname of the load balancer"
  value       = cloudflare_load_balancer.main.name
}

output "eu_pool_id" {
  description = "ID of the EU pool"
  value       = cloudflare_load_balancer_pool.eu_pool.id
}

output "us_pool_id" {
  description = "ID of the US pool"
  value       = cloudflare_load_balancer_pool.us_pool.id
}

output "eu_health_monitor_id" {
  description = "ID of the EU health monitor"
  value       = cloudflare_load_balancer_monitor.health_check_eu.id
}

output "us_health_monitor_id" {
  description = "ID of the US health monitor"
  value       = cloudflare_load_balancer_monitor.health_check_us.id
}

These outputs are useful for:

  • Referencing resources in other Terraform configurations
  • Debugging and monitoring through the Cloudflare dashboard
  • Integration with CI/CD pipelines

Step 8: Instantiate the Module for Multiple Environments

Now we can use our module to create load balancers for different environments. Create the root main.tf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# Staging environment
module "learn_api_staging" {
  source = "./modules/cloudflare-geo-load-balancer"

  account_id  = var.cloudflare_account_id
  zone_id     = var.cloudflare_zone_id
  name        = "learn-api"
  environment = "staging"
  hostname    = "staging-learn-api-lb.example.com"

  # Origin endpoints (e.g., EKS ingress, ALB, or direct server)
  eu_origin = "staging-learn-api-eu.example.com"
  us_origin = "staging-learn-api-us.example.com"

  # Health check configuration
  health_check_path             = "/health"
  health_check_interval         = 60
  health_check_timeout          = 5
  health_check_retries          = 2
  health_check_type             = "https"
  health_check_expected_codes   = "200"
  health_check_allow_insecure   = false
  health_check_follow_redirects = true

  # DNS configuration
  ttl     = 60
  proxied = true

  # Load balancer configuration
  session_affinity = "ip_cookie"
  steering_policy  = "geo"
}

# Production environment
module "learn_api_prod" {
  source = "./modules/cloudflare-geo-load-balancer"

  account_id  = var.cloudflare_account_id
  zone_id     = var.cloudflare_zone_id
  name        = "learn-api"
  environment = "prod"
  hostname    = "learn-api-lb.example.com"

  eu_origin = "learn-api-eu.example.com"
  us_origin = "learn-api-us.example.com"

  health_check_path             = "/health"
  health_check_interval         = 60
  health_check_timeout          = 5
  health_check_retries          = 2
  health_check_type             = "https"
  health_check_expected_codes   = "200"
  health_check_allow_insecure   = false
  health_check_follow_redirects = true

  ttl     = 60
  proxied = true

  session_affinity = "ip_cookie"
  steering_policy  = "geo"
}

And the root outputs.tf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# Staging outputs
output "staging_learn_api_load_balancer_id" {
  description = "Staging Learn API load balancer ID"
  value       = module.learn_api_staging.load_balancer_id
}

output "staging_learn_api_hostname" {
  description = "Staging Learn API hostname"
  value       = module.learn_api_staging.load_balancer_hostname
}

output "staging_learn_api_eu_pool_id" {
  description = "Staging Learn API EU pool ID"
  value       = module.learn_api_staging.eu_pool_id
}

output "staging_learn_api_us_pool_id" {
  description = "Staging Learn API US pool ID"
  value       = module.learn_api_staging.us_pool_id
}

# Production outputs
output "prod_learn_api_load_balancer_id" {
  description = "Production Learn API load balancer ID"
  value       = module.learn_api_prod.load_balancer_id
}

output "prod_learn_api_hostname" {
  description = "Production Learn API hostname"
  value       = module.learn_api_prod.load_balancer_hostname
}

output "prod_learn_api_eu_pool_id" {
  description = "Production Learn API EU pool ID"
  value       = module.learn_api_prod.eu_pool_id
}

output "prod_learn_api_us_pool_id" {
  description = "Production Learn API US pool ID"
  value       = module.learn_api_prod.us_pool_id
}

Notice how each environment uses the exact same module with different parameters. This is the power of Terraform modules — one codebase, multiple deployments, guaranteed consistency.

Step 9: Deploy the Infrastructure

Initialize Terraform and deploy:

1
2
3
4
5
6
7
8
# Initialize Terraform and download providers
terraform init

# Preview changes
terraform plan

# Apply changes
terraform apply

You should see output similar to:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Outputs:

staging_learn_api_hostname = "staging-learn-api-lb.example.com"
staging_learn_api_load_balancer_id = "abc123..."
staging_learn_api_eu_pool_id = "def456..."
staging_learn_api_us_pool_id = "ghi789..."
prod_learn_api_hostname = "learn-api-lb.example.com"
...

Verify the Setup

After deploying, verify your load balancer is working:

1
2
3
4
5
# Test from different regions using curl
curl -sI https://staging-learn-api-lb.example.com/health

# Check which region you're hitting by looking at response headers
curl -sI https://staging-learn-api-lb.example.com/health | grep -i cf-ray

The CF-Ray header includes a three-letter airport code indicating which Cloudflare data center handled the request (e.g., NBO for Nairobi, IAD for Washington DC).

Understanding Geo-Steering in Depth

Geo-steering uses a priority hierarchy to determine which pool serves a request. Understanding this hierarchy is critical for correct configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
┌─────────────────────────────────────────────┐
│   1. Country Pools (Highest Priority)        │
│   Match user's country → specific pool       │
│   e.g., KE → EU Pool, US → US Pool          │
├─────────────────────────────────────────────┤
│   2. Region Pools                            │
│   Match user's region → specific pool        │
│   e.g., WEU → EU Pool, WNAM → US Pool       │
├─────────────────────────────────────────────┤
│   3. Default Pools                           │
│   Used for unmatched regions/countries       │
│   Ordered list: [EU Pool, US Pool]           │
├─────────────────────────────────────────────┤
│   4. Fallback Pool (Lowest Priority)         │
│   Used when ALL pools are unhealthy          │
│   Last resort: EU Pool                       │
└─────────────────────────────────────────────┘

Example: Request from Kenya

  1. Country check: KE is defined in country_pools → routes to EU Pool
  2. Even though Kenya is in Africa (not WEU or EEU), the country-level rule takes precedence

Example: Request from Germany

  1. Country check: DE is NOT defined in country_pools
  2. Region check: Germany is in WEU → routes to EU Pool

Example: Request from Japan

  1. Country check: JP is NOT defined in country_pools
  2. Region check: No region pool defined for East Asia
  3. Default pools: Falls through to [EU Pool, US Pool] — EU Pool is tried first

Adding More Regions

To add coverage for more regions, simply add more region_pools blocks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Asia Pacific → US Pool (or create a dedicated APAC pool)
region_pools {
  region   = "EAS"
  pool_ids = [cloudflare_load_balancer_pool.us_pool.id]
}

# South America → US Pool
region_pools {
  region   = "WSAM"
  pool_ids = [cloudflare_load_balancer_pool.us_pool.id]
}

# Middle East → EU Pool
region_pools {
  region   = "ME"
  pool_ids = [cloudflare_load_balancer_pool.eu_pool.id]
}

Understanding Steering Policies

Cloudflare offers multiple steering policies. Choose based on your needs:

geo — Geographic Steering

Routes traffic based on the user’s geographic location using region_pools and country_pools. This is the most explicit and predictable option.

Best for: Applications with strict data residency requirements, or when you want explicit control over regional routing.

1
steering_policy = "geo"

dynamic_latency — Latency-Based Steering

Cloudflare measures round-trip time to each pool and routes users to the lowest-latency pool. The routing table is built dynamically based on observed performance.

Best for: Performance-optimized applications where the fastest response time matters most.

1
steering_policy = "dynamic_latency"

proximity_strict — Proximity Steering

Routes to the geographically closest pool based on the user’s resolver IP or EDNS Client Subnet data. Unlike geo, this does not require explicit region/country mapping.

Best for: Simple geographic routing without needing explicit region mapping. Works well as a free alternative to geo.

1
2
3
4
5
6
7
steering_policy = "proximity_strict"

# Configure location strategy
location_strategy {
  prefer_ecs = "never"
  mode       = "resolver_ip"
}

random — Random Steering

Distributes traffic randomly across all healthy pools. Each pool has an equal chance of receiving any request.

Best for: Simple load distribution when geography does not matter.

1
steering_policy = "random"

off — No Steering

Uses the default_pool_ids order. Traffic always goes to the first healthy pool. Acts as a simple active/passive failover.

Best for: Active/passive setups where you want all traffic on one pool unless it fails.

1
steering_policy = "off"

Session Affinity Explained

Session affinity ensures that subsequent requests from the same user go to the same origin server. This is important for applications that maintain server-side state (like sessions, shopping carts, or WebSocket connections).

none — No Affinity

Every request is routed independently. Best for stateless APIs.

1
session_affinity = "none"

Cloudflare sets a cookie (__cflb) that pins the user to a specific origin. The cookie is transparent to the user and your application.

1
session_affinity = "cookie"

Combines the user’s IP address with a cookie for stronger session pinning. Even if the cookie is lost, the IP address provides a fallback for affinity.

1
session_affinity = "ip_cookie"

This is the recommended option for most applications. It provides the best balance of reliability and consistency.

Health Checks and Automatic Failover

Health checks are what make load balancers truly valuable. Without them, you are just doing static DNS routing. Here is how the failover mechanism works:

Normal Operation

1
2
User (EU) → EU Pool ✅ → EU Origin (healthy)
User (US) → US Pool ✅ → US Origin (healthy)

EU Origin Becomes Unhealthy

After 2 consecutive failed health checks (configurable via retries):

1
2
User (EU) → EU Pool ❌ → Fallback to default pools → US Pool ✅ → US Origin
User (US) → US Pool ✅ → US Origin (healthy)

Both Origins Unhealthy

Traffic is sent to the fallback pool (EU Pool). Even when unhealthy, the fallback pool receives traffic as a last resort — this is better than returning an error to users.

1
2
User (EU) → All pools ❌ → Fallback pool (EU) → EU Origin (attempting recovery)
User (US) → All pools ❌ → Fallback pool (EU) → EU Origin (attempting recovery)

Implementing a Health Check Endpoint

Your application needs a /health endpoint. Here is a minimal example:

Node.js (Express):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
app.get("/health", (req, res) => {
  // Check database connectivity, cache, etc.
  const dbHealthy = checkDatabase();
  const cacheHealthy = checkCache();

  if (dbHealthy && cacheHealthy) {
    res.status(200).json({ status: "healthy" });
  } else {
    res.status(503).json({ status: "unhealthy" });
  }
});

Python (FastAPI):

1
2
3
4
5
6
@app.get("/health")
async def health_check():
    db_healthy = await check_database()
    if db_healthy:
        return {"status": "healthy"}
    raise HTTPException(status_code=503, detail="unhealthy")

Go (net/http):

1
2
3
4
5
6
7
8
9
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    if err := db.Ping(); err != nil {
        w.WriteHeader(http.StatusServiceUnavailable)
        json.NewEncoder(w).Encode(map[string]string{"status": "unhealthy"})
        return
    }
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
})

Tip: Keep health check endpoints lightweight. They are called every 60 seconds from multiple Cloudflare data centers. Avoid expensive database queries — a simple connection check is sufficient.

Best Practices

1. Always Use Proxied Mode

Setting proxied = true routes traffic through Cloudflare’s network, giving you DDoS protection, WAF, caching, and SSL termination for free. Only disable this if you have a specific reason (like needing to expose the origin IP).

2. Set Appropriate Health Check Intervals

  • 60 seconds: Good for production APIs. Balances responsiveness with server load.
  • 30 seconds: Better for critical services where faster failover is needed.
  • 120 seconds: Acceptable for non-critical services to reduce health check traffic.

For most applications, ip_cookie provides the best balance. It maintains session consistency while handling edge cases like cookie clearing.

4. Define Country Pools for Important Markets

If you have users concentrated in specific countries, define explicit country_pools to ensure they always hit the optimal region:

1
2
3
4
country_pools {
  country  = "KE"
  pool_ids = [cloudflare_load_balancer_pool.eu_pool.id]
}

5. Set the Fallback Pool to Your Most Reliable Region

The fallback pool is your safety net. Choose the region with the highest uptime and capacity.

6. Use Terraform Modules for Consistency

As shown in this guide, wrapping your load balancer configuration in a reusable module ensures consistency across environments and services. One bug fix or improvement applies everywhere.

7. Store State Remotely

For team environments, always use a remote backend for Terraform state:

1
2
3
4
5
6
7
backend "s3" {
  bucket         = "my-terraform-state"
  key            = "cloudflare/terraform.tfstate"
  region         = "us-west-2"
  encrypt        = true
  dynamodb_table = "terraform-state-lock"
}

8. Use Separate Health Monitors Per Region

Never share a health monitor between pools. Each monitor should have the correct Host header for its specific origin to ensure accurate health reporting.

Troubleshooting Common Issues

Health Checks Failing

Symptom: Pool shows as unhealthy in Cloudflare dashboard.

Common causes:

  • Wrong Host header: Ensure the header block in both the monitor and origin matches the origin’s expected hostname
  • SSL certificate issues: If using HTTPS, ensure allow_insecure = false and your origin has a valid certificate. For internal/self-signed certs, temporarily set allow_insecure = true
  • Firewall blocking Cloudflare IPs: Ensure your origin allows traffic from Cloudflare’s IP ranges
  • Health endpoint not responding with expected code: Verify your /health endpoint returns 200 (not 204, 301, etc.)

Traffic Not Routing to Expected Region

Symptom: Users in Europe are hitting the US pool.

Common causes:

  • DNS resolver location: The user’s DNS resolver might be in a different region. VPNs and public DNS services (like Google 8.8.8.8) can cause this
  • Missing region_pools entries: Ensure all relevant regions are defined
  • Country pool overriding region pool: Remember country_pools takes precedence over region_pools

Session Affinity Not Working

Symptom: Users are bouncing between origins.

Common causes:

  • Cookies blocked: The client might be blocking Cloudflare’s __cflb cookie. Use ip_cookie for better reliability
  • Multiple load balancer layers: If you have another load balancer (like AWS ALB) between Cloudflare and your origin, session affinity might be broken at that layer

Terraform Plan Shows Unexpected Changes

Symptom: Running terraform plan shows changes even though you haven’t modified anything.

Common causes:

  • Order sensitivity: Cloudflare may return pool IDs in a different order. Use lifecycle { ignore_changes } if needed
  • TTL being set when proxied: When proxied = true, TTL is managed by Cloudflare. The conditional ttl = var.proxied ? null : var.ttl handles this

Conclusion

Cloudflare Load Balancers with geo-steering provide a powerful way to distribute traffic globally, reduce latency, and ensure high availability for your applications. By using Terraform to manage this infrastructure, you get the benefits of version control, repeatability, and consistency across environments.

In this guide, we covered:

  • Setting up the Cloudflare Terraform provider
  • Building a reusable Terraform module for geo-distributed load balancers
  • Configuring health check monitors for automatic failover
  • Creating regional origin pools with proper Host headers
  • Implementing geo-steering with region and country-level routing
  • Managing session affinity for stateful applications
  • Deploying across multiple environments with a single module

The modular approach means adding a new environment or service is as simple as adding a new module block — all the complexity is encapsulated and tested.

For further reading, check out:

comments powered by Disqus
Citizix Ltd
Built with Hugo
Theme Stack designed by Jimmy