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!

Did you find this article valuable?

Support Caleb Faruki by becoming a sponsor. Any amount is appreciated!