Terraform recipe: domain-level redirects using Cloudflare
TLDR; Use Cloudflare's Terraform provider and defines a zone, a stub DNS record, and a redirect ruleset resource.
If you work for an organization of a sufficient size with strong brand recognition, you may encounter the problem of needing to buy any domain names remotely similar to your canonical domain name.
A minor benefit is that visitors can make mistakes typing your domain and still get where they need to go. A major benefit is mitigating phishing attacks.
A classic example that I have recently seen is DHL phishing attacks. They'll send a text message claiming that you need to pay tariffs on an international shipment before it clears customs. You land on a domain name like dlh.com instead of dhl.com and enter your credit card number to realize their site is broken. Then the real bad news settles in.
Thankfully a totally more legitimate company in another industry owns dlh.com. But the lesson remains important nevertheless.
Lesson aside, how do you manage this at scale when you're using Terraform and Cloudflare? Create a module that implements a Cloudflare Redirect Rule for a Cloudflare Zone. Add a main.tf
, variables.tf
, and outputs.tf
file for said module.
# ./modules/domain_redirect/variables.tf
variable cloudflare_account_id {
type = string
}
variable origin {
type = string
description = "Source hostname"
}
variable destination {
type = string
description = "Target hostname"
}
# ./modules/domain_redirect/main.tf
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.21.0"
}
}
}
resource cloudflare_zone zone {
account_id = var.cloudflare_account_id
zone = "${var.origin}"
}
resource cloudflare_record stub {
zone_id = cloudflare_zone.zone.id
comment = "Stubbed record needed to trigger DNS resolution and subsequent redirect"
type = "A"
name = "@"
value = "192.0.2.1"
proxied = true
ttl = 1
}
resource cloudflare_ruleset redirect {
zone_id = cloudflare_zone.zone.id
name = "Hostname redirect to ${var.destination}"
kind = "zone"
phase = "http_request_dynamic_redirect"
rules {
action = "redirect"
action_parameters {
from_value {
status_code = 301
target_url {
value = "https://${var.destination}"
}
preserve_query_string = true
}
}
expression = "http.host eq \"${var.origin}\""
description = "Redirect all requests from ${var.origin} to ${var.destination}"
enabled = true
}
}
You'll notice the DNS record above is called a stub. This is because Cloudflare does not execute any rulesets for a given zone until there is at least one root A or CNAME record. In my example above, I use a reserved IPv4 address belonging to the TEST-NET-1 IP block designated for documentation purposes by the IANA.
And in your parent main.tf
file, you can use a for_each
loop to map over every redirect you need to implement like so:
# ./main.tf
variable domain_redirects {
type = map(string)
default = {
"example1.com" = "example.com"
"example2.com" = "example.com"
"example1.fr" = "example.fr"
}
}
module domain_redirects {
cloudflare_account_id = local.cloudflare_account_id
source = "./modules/domain_redirect"
for_each = var.domain_redirects
origin = each.key
destination = each.value
}
The benefit of this map(string)
type variable is also that you can have multiple destinations in case your company has canonical domains for different use cases (like example.ai
) or regions (like example.fr
).
Lastly, you'll want to create an output value so that you'll see a more human-readable result for your domain-level redirects. First, you'll need to create an outputs file for your module:
#./module/domain_redirect/outputs.tf
output domain_redirect {
value = cloudflare_ruleset.redirect.rules[0].action_parameters[0].from_value[0].target_url[0].value
}
And then add another outputs.tf
file to the parent folder where your domain_redirect
module is used:
# ./outputs.tf
output domain_redirects {
value = { for domain, redirect in module.domain_redirects : domain => redirect.domain_redirect }
description = "The target URLs for each domain redirect"
}
After that, you get human readable output when you run your terraform plan
command.
module.domain_redirects["example1.com"].cloudflare_zone.zone: Refreshing state... [id=...]
module.domain_redirects["example1.com"].cloudflare_record.stub: Refreshing state... [id=...]
module.domain_redirects["example1.com"].cloudflare_ruleset.redirect: Refreshing state... [id=...]
module.domain_redirects["example2.com"].cloudflare_zone.zone: Refreshing state... [id=...]
module.domain_redirects["example2.com"].cloudflare_record.stub: Refreshing state... [id=...]
module.domain_redirects["example2.com"].cloudflare_ruleset.redirect: Refreshing state... [id=...]
module.domain_redirects["example1.fr"].cloudflare_zone.zone: Refreshing state... [id=...]
module.domain_redirects["example1.fr"].cloudflare_record.stub: Refreshing state... [id=...]
module.domain_redirects["example1.fr"].cloudflare_ruleset.redirect: Refreshing state... [id=...]
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
domain_redirects = {
"example1.com" = "https://example.com"
"example2.com" = "https://example.com"
"example1.fr" = "https://example.fr"
}
That's it!