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
- You front a public web app or API and want a global anycast entry point with TLS offload, HTTP/2, and latency-based routing instead of a single regional public IP.
- You need the Premium SKU to attach a managed WAF policy and/or reach backends privately over Private Link (the Standard SKU supports neither).
- You run an active/active or active/passive multi-region backend and want Front Door’s health probes to pull a failed region out of rotation automatically.
- You are migrating off Front Door (classic) /
azurerm_frontdoorand need the supportedcdn_frontdoor_*resource set. - You want apex/subdomain custom domains with Azure-managed TLS certificates and automatic renewal, declared as code.
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 config — live/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 config — live/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
- Lock your origins to this Front Door. Front Door’s public IP is shared, so an origin reachable on the internet can be hit directly, bypassing the edge and any WAF. Expose
resource_guid(output above) and have each origin reject requests whoseX-Azure-FDIDheader doesn’t match — App Service supports this natively via access restrictions. - Pick the SKU deliberately, because it gates real features and cost. WAF managed rulesets, bot protection, and Private Link origins are Premium-only; if you scaffold at Standard you cannot attach them later without recreating the profile. Standard is materially cheaper, so choose Premium only when you genuinely need those capabilities.
- Make health probes cheap and honest. Default to
HEADprobes against a dedicated lightweight/healthzthat checks real dependencies; reserveGETfor origins that reject HEAD. Aggressive intervals (e.g. 15s across many origins) multiply origin load and Front Door cost — tunehealth_probe_interval_secondsto your failover SLA, not lower. - Keep
certificate_name_check_enabled = true. Disabling origin certificate-name validation is a common shortcut that silently downgrades origin TLS to effectively unauthenticated; leave it on and use a correctorigin_host_headerinstead. - Prefer Azure-managed certificates for custom domains.
ManagedCertificateauto-renews and removes a recurring outage source; only fall back toCustomerCertificate(with a Key Vault reference) when policy demands your own CA. Always remember to create the_dnsauthTXT record fromcustom_domain_validation_tokenor validation never completes. - Standardise naming and tags through the module. Use an
afd-<app>-<env>profile convention and let the module stampmanaged-by/moduletags so every Front Door is attributable in cost reports and discoverable when auditing edge exposure across subscriptions.