Front Door: Edge TLS, WAF Links and Two Instructive Defects
The classic Front Door template does global entry properly - custom domains behind WAF policies, Key Vault TLS, HTTP-to-HTTPS redirect rules - and then teaches by counterexample: a hardcoded ARM resource ID where a lookup map should be, and a missing environment key silently swallowed by a forgiving default. Part 12 of the Terraform Module Catalog.
Application Gateway, last part's subject, guards one spoke. Azure Front Door guards everything: a global anycast entry point that terminates TLS at the edge, applies a WAF policy before traffic touches your network, and routes to whichever backend is healthy. The catalog's template targets classic Front Door (v1) — this library predates Standard/Premium — but the design questions it wrestles with are version-independent, and so are its two best defects.
This is part 12 of the Terraform Module Catalog, companion to the main Terraform on Azure series, which covers the shared chassis these templates ride on.
The surface: endpoints, pools, rules
Front Door's model is a triangle: frontend endpoints (the hostnames you answer on), backend pools (where traffic goes), and routing rules (which hostname-plus-path maps to which pool). The template exposes each as an object list, in the same names-as-foreign-keys style as the Application Gateway module:
frontend_endpoints_list = {
dev = [
{
name = "DefaultDomain"
host_name = "fd-platform-dev.azurefd.net"
web_application_firewall_policy_link_id = local.waf_policy_id
},
{
name = "orders-custom-domain"
host_name = "orders.dev.platform.example.com" # must CNAME to the azurefd.net domain
web_application_firewall_policy_link_id = local.waf_policy_id
},
]
staging = []
prod = []
}
backend_pools_list = {
dev = [{
name = "orders-pool"
backends = [{
host_header = "app-orders-dev.azurewebsites.net"
address = "app-orders-dev.azurewebsites.net"
https_port = 443
}]
}]
staging = []
prod = []
}
Two constraints the template documents in comments are worth repeating, because both bite in production. First, the Front Door name is not free: the resource name must match the label of its default .azurefd.net domain, so naming the instance is naming the endpoint. Second, a custom domain only works if a CNAME from your domain to the default domain already exists — DNS is a prerequisite, not an output, which couples this module to the DNS Zone module in deployment order even though Terraform sees no dependency between them.
The routing rules encode the pattern every public site wants and many misconfigure — a redirect rule and a forwarding rule as a pair:
routing_rules_list = {
dev = [
{
name = "orders-http-redirect"
accepted_protocols = ["Http"]
patterns_to_match = ["/*"]
frontend_endpoints = ["orders-custom-domain"]
redirect_configurations = [{
redirect_protocol = "HttpsOnly"
redirect_type = "Moved"
}]
},
{
name = "orders-https-forward"
accepted_protocols = ["Https"]
patterns_to_match = ["/*"]
frontend_endpoints = ["orders-custom-domain"]
forwarding_configurations = [{
forwarding_protocol = "MatchRequest"
backend_pool_name = "orders-pool"
}]
},
]
staging = []
prod = []
}
Plain HTTP never reaches a backend; it gets a 301 at the edge. TLS for the custom domain comes from Key Vault — certificate_source = "AzureKeyVault", a certificate name, and a secret version that the template leaves blank on purpose, because blank means “latest” and lets certificate rotation happen in the vault without a Terraform change. Health probes are their own small list (path plus protocol), and the module's enable_default_frontend_endpoint = false keeps the raw .azurefd.net hostname from serving traffic that should only ever arrive via the WAF-linked custom domain.
The warning in the comments
The template carries a comment that is the closest thing this catalog has to a scar, and it deserves paraphrasing: you cannot delete this Front Door until the DNS records for its custom domains are removed — with a pointer to Microsoft's subdomain-takeover guidance.
Unpack that and it is two lessons. The operational one: destroy has an ordering requirement that Terraform cannot see. The CNAME lives in a different stack (often a different team's stack); terraform destroy on the Front Door alone will fail — and Azure makes it fail deliberately. The security one is why: a CNAME pointing at a released .azurefd.net name is a dangling delegation. Anyone who registers that name afterwards serves traffic on your domain, inheriting your users' cookies and your brand's trust. Azure's refusal to delete while records point at the endpoint is a guardrail against creating that dangling state, and the correct teardown is a choreography: remove the CNAME, wait for TTL reality, then destroy. Cross-stack destroy ordering is exactly the kind of knowledge that dies in a comment — it belongs in runbooks, and ideally in a prevent_destroy lifecycle rule plus a checklist.
Defect one: the hardcoded resource ID
Now the counterexamples, anonymized as ever — patterns from an estate I worked on, valuable because they will happen to yours.
The WAF policy link in the template I studied was not a lookup. It was a complete, literal ARM resource ID — subscription GUID, resource group, provider path, policy name — pasted directly into the dev endpoint object:
web_application_firewall_policy_link_id =
"/subscriptions/00000000-.../resourcegroups/rg-shared-edge/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/platform-waf"
Everything about the chassis exists to prevent this line. The WAF policy is a platform fact — it belongs in the platform JSON, read with try(), resolved per environment like every subnet and Key Vault name in the library. Hardcoding it does three bad things at once: it embeds a real subscription ID in a template that gets copied (a confidentiality leak by propagation), it pins every environment of every copy to one policy in one subscription (the staging endpoint would link to a prod-owned WAF), and it hides an environment-varying value from the one file reviewers scan for environment differences. A golden template is judged by its worst line, because that line gets duplicated verbatim. This was the worst line.
Defect two: the key that wasn't there
The second defect is quieter. Every _list map in this template has dev, staging and prod keys — except one. The backend pools map defined dev and prod only. No staging key at all. And because the resolution used the forgiving form —
backend_pools = lookup(local.backend_pools_list, local.env, null)
— selecting the staging workspace produced not an error but a null, flowing into the module as “this Front Door has no backends”. Part 2 of the main series argued that forgiving lookups are the wrong kindness for values a resource cannot exist without; this is the specimen. An empty list would at least be visibly deliberate. A missing key masked by a default is a typo promoted to configuration. Strict indexing (local.backend_pools_list[local.env]) turns it into a plan-time error naming the map and the key — thirty seconds of diagnosis instead of a bewildering half-created edge resource. The same template also mixes shapes across keys — one environment's HTTPS config a map, another's an empty list — which the type-loose lookup happily tolerates until the module consumes it.
Defect three: the provider that opted out
Every other template in the catalog ships the standard provider pair — a default MSI-authenticated azurerm plus a shared alias into the hub subscription. This one ships a single provider, no alias, with its own version constraint declared inside the provider block (the pre-0.13 style that later Terraform deprecates) and a commented-out required_version. It is a small thing that predicts a big thing: when one module in a sixteen-module catalog manages its own provider versioning, the fleet's provider story stops being one decision and becomes sixteen. Its README, for good measure, links to a different module's sample folder — copy-paste lineage showing through, a theme the next two parts will make central.
Front Door decides how the world reaches you; the next module decides how the world finds you. Part 13, DNS Zone — one module, every record type, and a README that is secretly the best documentation in the library.