IaC Azure

Terraform Module: Azure Front Door — a reusable Standard/Premium edge with WAF-ready routing

Quick take — Build Azure Front Door (Standard/Premium) with Terraform azurerm ~> 4.0: profile, endpoint, origin group with health probes, origins, routes and custom domains as one var-driven module. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "front_door" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-front-door?ref=v1.0.0"

  name                = "..."           # Front Door profile name (3-260 chars, validated).
  resource_group_name = "..."           # Resource group holding the profile.
  endpoint_name       = "..."           # Endpoint name; forms the `*.azurefd.net` host.
  origins             = ["...", "..."]  # Backend origins (name, host, priority, weight, optional…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Front Door is Microsoft’s global, anycast Layer-7 entry point. A single Front Door endpoint advertises one anycast IP from every Microsoft edge POP, terminates TLS close to the user, and load-balances HTTP/S traffic across your backends (App Service, Storage static sites, AKS ingress, on-prem via Private Link, or any public host) using latency-based routing with active health probes. The modern SKU is the cdn_frontdoor family — azurerm_cdn_frontdoor_profile and its companions — which replaces the legacy azurerm_frontdoor (classic) resource. The two are not interchangeable, and new builds should always use the cdn_frontdoor_* resources.

The problem is that a working Front Door is never one resource. To actually serve traffic you need a profile, at least one endpoint, an origin group (with a health probe and load-balancing settings), one or more origins, and a route that binds the endpoint to the origin group. Custom domains add two more resources plus a validation dance. Wiring those six-to-eight resources by hand, correctly, on every project is exactly the kind of repetition a module exists to kill.

This module wraps that whole graph behind a handful of variables. You pass a name, a SKU, the origins, and (optionally) a custom domain, and you get back a fully-routed edge with sensible health-probe and timeout defaults — the same shape every time, reviewed once.

When to use it

If you only need a CDN cache for static assets with no routing or origin-group logic, the same profile resource works but most of this module’s surface (origin groups, routes, probes) is overkill — a thinner CDN-only module is a better fit.

Module structure

terraform-module-azure-front-door/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # profile, endpoint, origin group, origins, route, custom domain
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # ids, host names, and the DNS validation token

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

locals {
  # Front Door is a global service; the profile carries no region but tags help governance.
  base_tags = merge(var.tags, {
    "managed-by" = "terraform"
    "module"     = "terraform-module-azure-front-door"
  })
}

resource "azurerm_cdn_frontdoor_profile" "this" {
  name                     = var.name
  resource_group_name      = var.resource_group_name
  sku_name                 = var.sku_name
  response_timeout_seconds = var.response_timeout_seconds
  tags                     = local.base_tags
}

resource "azurerm_cdn_frontdoor_endpoint" "this" {
  name                     = var.endpoint_name
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.this.id
  enabled                  = true
  tags                     = local.base_tags
}

resource "azurerm_cdn_frontdoor_origin_group" "this" {
  name                     = var.origin_group_name
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.this.id

  session_affinity_enabled = var.session_affinity_enabled

  # Pull an origin out of rotation after this many failed probes.
  restore_traffic_time_to_healed_or_new_endpoint_in_minutes = var.restore_traffic_time_minutes

  load_balancing {
    sample_size                        = var.lb_sample_size
    successful_samples_required        = var.lb_successful_samples_required
    additional_latency_in_milliseconds = var.lb_additional_latency_ms
  }

  health_probe {
    path                = var.health_probe_path
    protocol            = var.health_probe_protocol
    request_type        = var.health_probe_request_type
    interval_in_seconds = var.health_probe_interval_seconds
  }
}

resource "azurerm_cdn_frontdoor_origin" "this" {
  for_each = { for o in var.origins : o.name => o }

  name                          = each.value.name
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.this.id
  enabled                       = each.value.enabled

  host_name                      = each.value.host_name
  http_port                      = each.value.http_port
  https_port                     = each.value.https_port
  origin_host_header             = coalesce(each.value.origin_host_header, each.value.host_name)
  priority                       = each.value.priority
  weight                         = each.value.weight
  certificate_name_check_enabled = each.value.certificate_name_check_enabled

  # Optional Private Link origin (Premium SKU only).
  dynamic "private_link" {
    for_each = each.value.private_link == null ? [] : [each.value.private_link]
    content {
      request_message        = private_link.value.request_message
      target_type            = private_link.value.target_type
      location               = private_link.value.location
      private_link_target_id = private_link.value.private_link_target_id
    }
  }
}

resource "azurerm_cdn_frontdoor_route" "this" {
  name                          = var.route_name
  cdn_frontdoor_endpoint_id     = azurerm_cdn_frontdoor_endpoint.this.id
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.this.id
  cdn_frontdoor_origin_ids      = [for o in azurerm_cdn_frontdoor_origin.this : o.id]

  enabled                = true
  forwarding_protocol    = var.forwarding_protocol
  https_redirect_enabled = var.https_redirect_enabled
  patterns_to_match      = var.patterns_to_match
  supported_protocols    = var.supported_protocols
  link_to_default_domain = var.custom_domain == null ? true : var.link_to_default_domain

  cdn_frontdoor_custom_domain_ids = var.custom_domain == null ? [] : [
    azurerm_cdn_frontdoor_custom_domain.this[0].id
  ]

  cache {
    query_string_caching_behavior = var.cache_query_string_behavior
    compression_enabled           = var.cache_compression_enabled
    content_types_to_compress     = var.cache_content_types_to_compress
  }
}

resource "azurerm_cdn_frontdoor_custom_domain" "this" {
  count = var.custom_domain == null ? 0 : 1

  name                     = replace(var.custom_domain.host_name, ".", "-")
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.this.id
  host_name                = var.custom_domain.host_name
  dns_zone_id              = var.custom_domain.dns_zone_id

  tls {
    certificate_type    = var.custom_domain.certificate_type
    minimum_tls_version = var.custom_domain.minimum_tls_version
  }
}

# Bind the validated custom domain back to the route's origin group.
resource "azurerm_cdn_frontdoor_route_disable_link_to_default_domain" "this" {
  count = var.custom_domain != null && var.link_to_default_domain == false ? 1 : 0

  cdn_frontdoor_route_ids          = [azurerm_cdn_frontdoor_route.this.id]
  cdn_frontdoor_custom_domain_ids  = [azurerm_cdn_frontdoor_custom_domain.this[0].id]
}

variables.tf

variable "name" {
  description = "Name of the Front Door profile. Globally scoped within the subscription/RG."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]{1,258}[a-zA-Z0-9]$", var.name))
    error_message = "Profile name must be 3-260 chars, alphanumeric or hyphen, and not start/end with a hyphen."
  }
}

variable "resource_group_name" {
  description = "Resource group that holds the Front Door profile metadata."
  type        = string
}

variable "sku_name" {
  description = "Front Door SKU. Premium is required for WAF managed rulesets and Private Link origins."
  type        = string
  default     = "Standard_AzureFrontDoor"

  validation {
    condition     = contains(["Standard_AzureFrontDoor", "Premium_AzureFrontDoor"], var.sku_name)
    error_message = "sku_name must be Standard_AzureFrontDoor or Premium_AzureFrontDoor."
  }
}

variable "response_timeout_seconds" {
  description = "Seconds Front Door waits for a response from an origin before returning an error (16-240)."
  type        = number
  default     = 60

  validation {
    condition     = var.response_timeout_seconds >= 16 && var.response_timeout_seconds <= 240
    error_message = "response_timeout_seconds must be between 16 and 240."
  }
}

variable "endpoint_name" {
  description = "Name of the Front Door endpoint. Forms the *.azurefd.net default host."
  type        = string
}

variable "origin_group_name" {
  description = "Name of the origin group that backs the route."
  type        = string
  default     = "default-origin-group"
}

variable "route_name" {
  description = "Name of the route binding the endpoint to the origin group."
  type        = string
  default     = "default-route"
}

variable "session_affinity_enabled" {
  description = "Pin a client to the same origin via a Front Door cookie."
  type        = bool
  default     = false
}

variable "restore_traffic_time_minutes" {
  description = "Gradual ramp time (minutes) before a healed/new origin receives full traffic (0-50)."
  type        = number
  default     = 10

  validation {
    condition     = var.restore_traffic_time_minutes >= 0 && var.restore_traffic_time_minutes <= 50
    error_message = "restore_traffic_time_minutes must be between 0 and 50."
  }
}

variable "lb_sample_size" {
  description = "Number of samples to consider for load balancing decisions."
  type        = number
  default     = 4
}

variable "lb_successful_samples_required" {
  description = "Successful samples within the sample window required to mark an origin healthy."
  type        = number
  default     = 3
}

variable "lb_additional_latency_ms" {
  description = "Latency band (ms) within which origins are treated as equally fast for routing."
  type        = number
  default     = 50
}

variable "health_probe_path" {
  description = "Path Front Door probes on each origin to determine health."
  type        = string
  default     = "/"
}

variable "health_probe_protocol" {
  description = "Protocol used for health probes (Http or Https)."
  type        = string
  default     = "Https"

  validation {
    condition     = contains(["Http", "Https"], var.health_probe_protocol)
    error_message = "health_probe_protocol must be Http or Https."
  }
}

variable "health_probe_request_type" {
  description = "Probe HTTP method. HEAD is cheaper; GET is needed when origins reject HEAD."
  type        = string
  default     = "HEAD"

  validation {
    condition     = contains(["GET", "HEAD"], var.health_probe_request_type)
    error_message = "health_probe_request_type must be GET or HEAD."
  }
}

variable "health_probe_interval_seconds" {
  description = "Seconds between health probes (1-255)."
  type        = number
  default     = 30

  validation {
    condition     = var.health_probe_interval_seconds >= 1 && var.health_probe_interval_seconds <= 255
    error_message = "health_probe_interval_seconds must be between 1 and 255."
  }
}

variable "forwarding_protocol" {
  description = "Protocol Front Door uses to reach origins: HttpOnly, HttpsOnly, or MatchRequest."
  type        = string
  default     = "HttpsOnly"

  validation {
    condition     = contains(["HttpOnly", "HttpsOnly", "MatchRequest"], var.forwarding_protocol)
    error_message = "forwarding_protocol must be HttpOnly, HttpsOnly, or MatchRequest."
  }
}

variable "https_redirect_enabled" {
  description = "Automatically redirect HTTP requests to HTTPS at the edge."
  type        = bool
  default     = true
}

variable "patterns_to_match" {
  description = "Route path patterns. '/*' matches all paths."
  type        = list(string)
  default     = ["/*"]
}

variable "supported_protocols" {
  description = "Protocols the route accepts from clients."
  type        = list(string)
  default     = ["Http", "Https"]
}

variable "link_to_default_domain" {
  description = "Whether the route also serves the *.azurefd.net default domain when a custom domain is set."
  type        = bool
  default     = true
}

variable "cache_query_string_behavior" {
  description = "Query string caching behavior: IgnoreQueryString, UseQueryString, IgnoreSpecifiedQueryStrings, IncludeSpecifiedQueryStrings."
  type        = string
  default     = "IgnoreQueryString"
}

variable "cache_compression_enabled" {
  description = "Enable edge compression of compressible content types."
  type        = bool
  default     = true
}

variable "cache_content_types_to_compress" {
  description = "MIME types Front Door compresses at the edge."
  type        = list(string)
  default = [
    "application/json",
    "application/javascript",
    "text/css",
    "text/html",
    "text/plain",
  ]
}

variable "origins" {
  description = "List of backend origins served behind the origin group."
  type = list(object({
    name                           = string
    host_name                      = string
    enabled                        = optional(bool, true)
    http_port                      = optional(number, 80)
    https_port                     = optional(number, 443)
    origin_host_header             = optional(string)
    priority                       = optional(number, 1)
    weight                         = optional(number, 1000)
    certificate_name_check_enabled = optional(bool, true)
    private_link = optional(object({
      request_message        = string
      target_type            = optional(string)
      location               = string
      private_link_target_id = string
    }))
  }))

  validation {
    condition     = length(var.origins) > 0
    error_message = "At least one origin must be provided."
  }

  validation {
    condition     = alltrue([for o in var.origins : o.priority >= 1 && o.priority <= 5])
    error_message = "Each origin priority must be between 1 and 5."
  }

  validation {
    condition     = alltrue([for o in var.origins : o.weight >= 1 && o.weight <= 1000])
    error_message = "Each origin weight must be between 1 and 1000."
  }
}

variable "custom_domain" {
  description = "Optional custom domain with Azure-managed or customer TLS. Leave null to serve only the default host."
  type = object({
    host_name           = string
    dns_zone_id         = optional(string)
    certificate_type    = optional(string, "ManagedCertificate")
    minimum_tls_version = optional(string, "TLS12")
  })
  default = null

  validation {
    condition = var.custom_domain == null ? true : contains(
      ["ManagedCertificate", "CustomerCertificate"], var.custom_domain.certificate_type
    )
    error_message = "custom_domain.certificate_type must be ManagedCertificate or CustomerCertificate."
  }
}

variable "tags" {
  description = "Tags applied to the profile, endpoint, and origin group."
  type        = map(string)
  default     = {}
}

outputs.tf

output "profile_id" {
  description = "Resource ID of the Front Door profile."
  value       = azurerm_cdn_frontdoor_profile.this.id
}

output "profile_name" {
  description = "Name of the Front Door profile."
  value       = azurerm_cdn_frontdoor_profile.this.name
}

output "resource_guid" {
  description = "Stable GUID of the profile, used when configuring origin access restrictions (X-Azure-FDID)."
  value       = azurerm_cdn_frontdoor_profile.this.resource_guid
}

output "endpoint_id" {
  description = "Resource ID of the Front Door endpoint."
  value       = azurerm_cdn_frontdoor_endpoint.this.id
}

output "endpoint_host_name" {
  description = "Default *.azurefd.net host name of the endpoint."
  value       = azurerm_cdn_frontdoor_endpoint.this.host_name
}

output "origin_group_id" {
  description = "Resource ID of the origin group."
  value       = azurerm_cdn_frontdoor_origin_group.this.id
}

output "origin_ids" {
  description = "Map of origin name to resource ID."
  value       = { for k, o in azurerm_cdn_frontdoor_origin.this : k => o.id }
}

output "route_id" {
  description = "Resource ID of the route."
  value       = azurerm_cdn_frontdoor_route.this.id
}

output "custom_domain_id" {
  description = "Resource ID of the custom domain, or null when none is configured."
  value       = var.custom_domain == null ? null : azurerm_cdn_frontdoor_custom_domain.this[0].id
}

output "custom_domain_validation_token" {
  description = "DNS TXT validation token for the custom domain (use to create the _dnsauth record)."
  value       = var.custom_domain == null ? null : azurerm_cdn_frontdoor_custom_domain.this[0].validation_token
}

How to use it

data "azurerm_dns_zone" "public" {
  name                = "kloudvin.com"
  resource_group_name = "rg-dns-prod"
}

module "front_door" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-front-door?ref=v1.0.0"

  name                = "afd-kloudvin-prod"
  resource_group_name = "rg-edge-prod"
  sku_name            = "Premium_AzureFrontDoor"
  endpoint_name       = "kloudvin-web"

  # Active/passive across two regions; West Europe is primary (priority 1).
  origins = [
    {
      name      = "weu-app"
      host_name = "app-kloudvin-weu.azurewebsites.net"
      priority  = 1
      weight    = 1000
    },
    {
      name      = "neu-app"
      host_name = "app-kloudvin-neu.azurewebsites.net"
      priority  = 2
      weight    = 1000
    },
  ]

  health_probe_path             = "/healthz"
  health_probe_request_type     = "GET"
  health_probe_interval_seconds = 15

  custom_domain = {
    host_name   = "www.kloudvin.com"
    dns_zone_id = data.azurerm_dns_zone.public.id
  }

  tags = {
    environment = "prod"
    owner       = "platform-team"
  }
}

# Downstream: publish the managed-cert validation token as a DNS TXT record
# so the custom domain can complete domain ownership validation.
resource "azurerm_dns_txt_record" "fd_validation" {
  count               = module.front_door.custom_domain_validation_token == null ? 0 : 1
  name                = "_dnsauth.www"
  zone_name           = data.azurerm_dns_zone.public.name
  resource_group_name = data.azurerm_dns_zone.public.resource_group_name
  ttl                 = 3600

  record {
    value = module.front_door.custom_domain_validation_token
  }
}

# Downstream: CNAME the custom hostname to the Front Door endpoint.
resource "azurerm_dns_cname_record" "www" {
  name                = "www"
  zone_name           = data.azurerm_dns_zone.public.name
  resource_group_name = data.azurerm_dns_zone.public.resource_group_name
  ttl                 = 3600
  record              = module.front_door.endpoint_host_name
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/front_door/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-front-door?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  endpoint_name = "..."
  origins = ["...", "..."]
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/front_door && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string yes Front Door profile name (3-260 chars, validated).
resource_group_name string yes Resource group holding the profile.
sku_name string "Standard_AzureFrontDoor" no Standard_AzureFrontDoor or Premium_AzureFrontDoor (Premium adds WAF/Private Link).
response_timeout_seconds number 60 no Origin response timeout, 16-240.
endpoint_name string yes Endpoint name; forms the *.azurefd.net host.
origin_group_name string "default-origin-group" no Origin group name.
route_name string "default-route" no Route name.
session_affinity_enabled bool false no Pin clients to an origin via a Front Door cookie.
restore_traffic_time_minutes number 10 no Ramp time for healed/new origins, 0-50.
lb_sample_size number 4 no Samples considered for load-balancing decisions.
lb_successful_samples_required number 3 no Successful samples needed to mark an origin healthy.
lb_additional_latency_ms number 50 no Latency band treated as equally fast.
health_probe_path string "/" no Path probed on each origin.
health_probe_protocol string "Https" no Http or Https.
health_probe_request_type string "HEAD" no GET or HEAD.
health_probe_interval_seconds number 30 no Seconds between probes, 1-255.
forwarding_protocol string "HttpsOnly" no HttpOnly, HttpsOnly, or MatchRequest.
https_redirect_enabled bool true no Redirect HTTP to HTTPS at the edge.
patterns_to_match list(string) ["/*"] no Route path patterns.
supported_protocols list(string) ["Http","Https"] no Client protocols the route accepts.
link_to_default_domain bool true no Serve the default *.azurefd.net host alongside a custom domain.
cache_query_string_behavior string "IgnoreQueryString" no Query-string caching behavior.
cache_compression_enabled bool true no Enable edge compression.
cache_content_types_to_compress list(string) JSON/JS/CSS/HTML/text no MIME types compressed at the edge.
origins list(object) yes Backend origins (name, host, priority, weight, optional Private Link).
custom_domain object null no Custom domain with managed/customer TLS.
tags map(string) {} no Tags applied to profile, endpoint, and origin group.

Outputs

Name Description
profile_id Resource ID of the Front Door profile.
profile_name Name of the Front Door profile.
resource_guid Profile GUID (the X-Azure-FDID value used to lock origins to this Front Door).
endpoint_id Resource ID of the endpoint.
endpoint_host_name Default *.azurefd.net host name of the endpoint.
origin_group_id Resource ID of the origin group.
origin_ids Map of origin name to resource ID.
route_id Resource ID of the route.
custom_domain_id Resource ID of the custom domain, or null.
custom_domain_validation_token DNS TXT token for _dnsauth domain validation, or null.

Enterprise scenario

A retail platform runs its checkout API on App Service in West Europe with a warm standby in North Europe. The platform team consumes this module at the Premium SKU to put a single global endpoint in front of both regions: priority = 1 on West Europe, priority = 2 on North Europe, and a /healthz probe every 15 seconds. When a West Europe deployment goes bad and starts failing probes, Front Door drains it within two probe windows and shifts checkout traffic to North Europe with no DNS change and no customer impact — then ramps West Europe back in over restore_traffic_time_minutes once it recovers. Because the SKU is Premium, the same profile later gains a WAF policy and Private Link origins without re-platforming.

Best practices

TerraformAzureFront DoorModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading