Azure Front Door Fronting an AKS NGINX Ingress (Public)

Terraform Set: Azure Front Door

(Standard or Premium)

Fronting an AKS NGINX Ingress (Public)

This Deployment Creates:

  1. Resource Group and Networking (VNet, Subnet)
  2. AKS Cluster (System Node Pool)
  3. Static Public IP for NGINX Ingress LoadBalancer
  4. NGINX Ingress Controller via Helm, Bound to the Static Public IP
  5. A sample Kubernetes Ingress Rule (Optional) to Prove Routing
  6. Azure Front Door Profile, Endpoint, Origin Group, Origin (Pointing to the NGINX Public IP), and Route
  7. Optional WAF Policy and Association
  8. Optional Diagnostics to Log Analytics

Notes you should know up Front:

  1. This Pattern uses a public NGINX ingress IP as the Front Door origin. It is the most common and simplest AKS + Front Door design.
  2. For Production Hardening, you typically restrict Direct Access to the Ingress Public IP (at least by WAF Rules, or by IP allowlisting, or by moving to Premium + Private Link Patterns depending on requirements). I include practical Hardening Guidance at the End.
  3. This is a complete “Single Apply” set, but the NGINX Service may take a few minutes to allocate and Bind the Static IP after the Cluster is ready.

Folder Layout:

Versions.tf

Providers.tf

Variables.tf

Main.tf

Outputs.tf

Terraform.tfvars (Example)

Versions.tf

 

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 3.80.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = ">= 2.25.0"
    }
    helm = {
      source  = "hashicorp/helm"
      version = ">= 2.12.0"
    }
  }
}

Providers.tf

provider "azurerm" {
  features {}
}

data "azurerm_client_config" "current" {}

provider "kubernetes" {
  host                   = azurerm_kubernetes_cluster.aks.kube_config[0].host
  client_certificate     = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].client_certificate)
  client_key             = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].client_key)
  cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].cluster_ca_certificate)
}

provider "helm" {
  kubernetes {
    host                   = azurerm_kubernetes_cluster.aks.kube_config[0].host
    client_certificate     = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].client_certificate)
    client_key             = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].client_key)
    cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster.aks.kube_config[0].cluster_ca_certificate)
  }
}

Variables.tf

variable "resource_group_name" {
  type        = string
  description = "Resource group name."
}

variable "location" {
  type        = string
  description = "Azure region for AKS and network."
  default     = "eastus"
}

variable "vnet_name" {
  type        = string
  default     = "vnet-aks"
}

variable "vnet_cidr" {
  type        = string
  default     = "10.10.0.0/16"
}

variable "aks_subnet_name" {
  type        = string
  default     = "snet-aks"
}

variable "aks_subnet_cidr" {
  type        = string
  default     = "10.10.1.0/24"
}

variable "aks_name" {
  type        = string
  description = "AKS cluster name."
}

variable "dns_prefix" {
  type        = string
  description = "AKS DNS prefix."
}

variable "kubernetes_version" {
  type        = string
  description = "AKS Kubernetes version. Keep empty to let Azure choose default."
  default     = ""
}

variable "system_node_vm_size" {
  type        = string
  default     = "Standard_D4s_v5"
}

variable "system_node_count" {
  type        = number
  default     = 3
}

variable "aks_sku_tier" {
  type        = string
  default     = "Standard"
}

variable "ingress_namespace" {
  type        = string
  default     = "ingress-nginx"
}

variable "ingress_public_ip_name" {
  type        = string
  default     = "pip-aks-ingress"
}

variable "ingress_controller_release_name" {
  type        = string
  default     = "ingress-nginx"
}

variable "app_namespace" {
  type        = string
  default     = "demo"
}

variable "app_host_name" {
  type        = string
  description = "Host header that Front Door will send to the ingress. Use a domain your ingress rules expect, such as app.contoso.com."
}

variable "frontdoor_profile_name" {
  type        = string
  description = "Front Door profile name."
}

variable "frontdoor_endpoint_name" {
  type        = string
  description = "Front Door endpoint name."
}

variable "frontdoor_sku" {
  type        = string
  description = "Standard_AzureFrontDoor or Premium_AzureFrontDoor."
  default     = "Standard_AzureFrontDoor"

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

variable "origin_group_name" {
  type    = string
  default = "og-aks"
}

variable "route_name" {
  type    = string
  default = "route-aks"
}

variable "health_probe_path" {
  type    = string
  default = "/healthz"
}

variable "waf_enabled" {
  type    = bool
  default = true
}

variable "waf_policy_name" {
  type    = string
  default = "waf-frontdoor"
}

variable "security_policy_name" {
  type    = string
  default = "fd-security-policy"
}

variable "waf_mode" {
  type    = string
  default = "Prevention"

  validation {
    condition     = contains(["Detection", "Prevention"], var.waf_mode)
    error_message = "waf_mode must be Detection or Prevention."
  }
}

variable "diagnostics_enabled" {
  type    = bool
  default = false
}

variable "log_analytics_workspace_id" {
  type        = string
  default     = null
  description = "Required if diagnostics_enabled is true."
}

Main.tf

resource "azurerm_resource_group" "rg" {
  name     = var.resource_group_name
  location = var.location
}

resource "azurerm_virtual_network" "vnet" {
  name                = var.vnet_name
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  address_space       = [var.vnet_cidr]
}

resource "azurerm_subnet" "aks" {
  name                 = var.aks_subnet_name
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = [var.aks_subnet_cidr]
}

resource "azurerm_kubernetes_cluster" "aks" {
  name                = var.aks_name
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  dns_prefix          = var.dns_prefix
  sku_tier            = var.aks_sku_tier

  kubernetes_version = var.kubernetes_version != "" ? var.kubernetes_version : null

  identity {
    type = "SystemAssigned"
  }

  default_node_pool {
    name                = "sysnp"
    node_count          = var.system_node_count
    vm_size             = var.system_node_vm_size
    vnet_subnet_id      = azurerm_subnet.aks.id
    orchestrator_version = var.kubernetes_version != "" ? var.kubernetes_version : null
    type                = "VirtualMachineScaleSets"
  }

  network_profile {
    network_plugin = "azure"
    network_policy = "azure"
    outbound_type  = "loadBalancer"
  }

  oidc_issuer_enabled       = true
  workload_identity_enabled = true
}

resource "azurerm_role_assignment" "aks_rg_network_contributor" {
  scope                = azurerm_resource_group.rg.id
  role_definition_name = "Network Contributor"
  principal_id         = azurerm_kubernetes_cluster.aks.identity[0].principal_id
}

resource "azurerm_public_ip" "ingress" {
  name                = var.ingress_public_ip_name
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  allocation_method = "Static"
  sku               = "Standard"
}

resource "kubernetes_namespace" "ingress" {
  metadata {
    name = var.ingress_namespace
  }

  depends_on = [azurerm_kubernetes_cluster.aks]
}

resource "helm_release" "ingress_nginx" {
  name       = var.ingress_controller_release_name
  namespace  = kubernetes_namespace.ingress.metadata[0].name
  repository = "https://kubernetes.github.io/ingress-nginx"
  chart      = "ingress-nginx"
  version    = "4.11.0"

  set {
    name  = "controller.service.type"
    value = "LoadBalancer"
  }

  set {
    name  = "controller.service.loadBalancerIP"
    value = azurerm_public_ip.ingress.ip_address
  }

  set {
    name  = "controller.service.annotations.service\\.beta\\.kubernetes\\.io/azure-load-balancer-resource-group"
    value = azurerm_resource_group.rg.name
  }

  set {
    name  = "controller.config.use-forwarded-headers"
    value = "true"
  }

  set {
    name  = "controller.config.compute-full-forwarded-for"
    value = "true"
  }

  set {
    name  = "controller.config.enable-real-ip"
    value = "true"
  }

  depends_on = [
    azurerm_role_assignment.aks_rg_network_contributor
  ]
}

resource "kubernetes_namespace" "app" {
  metadata {
    name = var.app_namespace
  }

  depends_on = [azurerm_kubernetes_cluster.aks]
}

resource "kubernetes_deployment" "demo" {
  metadata {
    name      = "demo-web"
    namespace = kubernetes_namespace.app.metadata[0].name
    labels = {
      app = "demo-web"
    }
  }

  spec {
    replicas = 2

    selector {
      match_labels = {
        app = "demo-web"
      }
    }

    template {
      metadata {
        labels = {
          app = "demo-web"
        }
      }

      spec {
        container {
          name  = "demo-web"
          image = "nginxdemos/hello:plain-text"

          port {
            container_port = 80
          }

          readiness_probe {
            http_get {
              path = "/"
              port = 80
            }
            initial_delay_seconds = 5
            period_seconds        = 10
          }

          liveness_probe {
            http_get {
              path = "/"
              port = 80
            }
            initial_delay_seconds = 15
            period_seconds        = 20
          }
        }
      }
    }
  }

  depends_on = [helm_release.ingress_nginx]
}

resource "kubernetes_service" "demo" {
  metadata {
    name      = "demo-web-svc"
    namespace = kubernetes_namespace.app.metadata[0].name
    labels = {
      app = "demo-web"
    }
  }

  spec {
    selector = {
      app = "demo-web"
    }

    port {
      port        = 80
      target_port = 80
    }

    type = "ClusterIP"
  }

  depends_on = [kubernetes_deployment.demo]
}

resource "kubernetes_ingress_v1" "demo" {
  metadata {
    name      = "demo-web-ing"
    namespace = kubernetes_namespace.app.metadata[0].name
    annotations = {
      "kubernetes.io/ingress.class" = "nginx"
    }
  }

  spec {
    rule {
      host = var.app_host_name

      http {
        path {
          path      = "/"
          path_type = "Prefix"

          backend {
            service {
              name = kubernetes_service.demo.metadata[0].name
              port {
                number = 80
              }
            }
          }
        }
      }
    }
  }

  depends_on = [helm_release.ingress_nginx]
}

resource "azurerm_cdn_frontdoor_profile" "fd_profile" {
  name                = var.frontdoor_profile_name
  resource_group_name = azurerm_resource_group.rg.name
  sku_name            = var.frontdoor_sku
}

resource "azurerm_cdn_frontdoor_endpoint" "fd_endpoint" {
  name                     = var.frontdoor_endpoint_name
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.fd_profile.id
  enabled                  = true
}

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

  session_affinity_enabled = false

  load_balancing {
    additional_latency_in_milliseconds = 0
    sample_size                        = 4
    successful_samples_required         = 3
  }

  health_probe {
    interval_in_seconds = 30
    path                = var.health_probe_path
    protocol            = "Http"
    request_type        = "GET"
  }
}

resource "azurerm_cdn_frontdoor_origin" "fd_origin" {
  name                          = "aks-nginx-ingress"
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.fd_origin_group.id

  enabled                        = true
  host_name                      = azurerm_public_ip.ingress.ip_address
  origin_host_header             = var.app_host_name
  http_port                      = 80
  https_port                     = 443
  priority                       = 1
  weight                         = 1000
  certificate_name_check_enabled = false

  depends_on = [helm_release.ingress_nginx]
}

resource "azurerm_cdn_frontdoor_route" "fd_route" {
  name                          = var.route_name
  cdn_frontdoor_endpoint_id     = azurerm_cdn_frontdoor_endpoint.fd_endpoint.id
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.fd_origin_group.id
  cdn_frontdoor_origin_ids      = [azurerm_cdn_frontdoor_origin.fd_origin.id]

  enabled                = true
  forwarding_protocol    = "HttpOnly"
  https_redirect_enabled = true
  patterns_to_match      = ["/*"]
  supported_protocols    = ["Http", "Https"]
  link_to_default_domain = true

  cache {
    query_string_caching_behavior = "IgnoreQueryString"
    compression_enabled           = true
    content_types_to_compress     = ["text/plain", "text/html", "text/css", "application/javascript", "application/json", "text/xml"]
  }
}

resource "azurerm_cdn_frontdoor_firewall_policy" "fd_waf" {
  count               = var.waf_enabled ? 1 : 0
  name                = var.waf_policy_name
  resource_group_name = azurerm_resource_group.rg.name
  sku_name            = var.frontdoor_sku

  enabled = true
  mode    = var.waf_mode

  managed_rule {
    type    = "Microsoft_DefaultRuleSet"
    version = "2.1"
    action  = "Block"
  }
}

resource "azurerm_cdn_frontdoor_security_policy" "fd_security_policy" {
  count                    = var.waf_enabled ? 1 : 0
  name                     = var.security_policy_name
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.fd_profile.id

  security_policies {
    firewall {
      cdn_frontdoor_firewall_policy_id = azurerm_cdn_frontdoor_firewall_policy.fd_waf[0].id

      association {
        patterns_to_match = ["/*"]

        domain {
          cdn_frontdoor_domain_id = azurerm_cdn_frontdoor_endpoint.fd_endpoint.id
        }
      }
    }
  }
}

resource "azurerm_monitor_diagnostic_setting" "fd_diagnostics" {
  count                      = var.diagnostics_enabled ? 1 : 0
  name                       = "fd-diag"
  target_resource_id         = azurerm_cdn_frontdoor_profile.fd_profile.id
  log_analytics_workspace_id = var.log_analytics_workspace_id

  enabled_log {
    category = "FrontdoorAccessLog"
  }

  enabled_log {
    category = "FrontdoorWebApplicationFirewallLog"
  }

  metric {
    category = "AllMetrics"
    enabled  = true
  }
}

Outputs.tf

output "aks_name" {
  value = azurerm_kubernetes_cluster.aks.name
}

output "ingress_public_ip" {
  value = azurerm_public_ip.ingress.ip_address
}

output "frontdoor_default_hostname" {
  value = azurerm_cdn_frontdoor_endpoint.fd_endpoint.host_name
}

output "frontdoor_profile_id" {

  value = azurerm_cdn_frontdoor_profile.fd_profile.id
}

Example Terraform.tfvars

resource_group_name = "rg-aks-fd-prod"
location            = "eastus"

aks_name    = "aks-prod-01"
dns_prefix  = "aksprod01"

system_node_vm_size  = "Standard_D4s_v5"
system_node_count    = 3
aks_sku_tier         = "Standard"

app_host_name = "app.contoso.com"

frontdoor_profile_name  = "fd-profile-prod"
frontdoor_endpoint_name = "fd-endpoint-prod"
frontdoor_sku           = "Standard_AzureFrontDoor"

origin_group_name = "og-aks-prod"
route_name        = "route-aks-prod"

health_probe_path = "/healthz"

waf_enabled          = true
waf_policy_name      = "waf-fd-prod"
security_policy_name = "fd-sec-prod"
waf_mode             = "Prevention"

diagnostics_enabled        = false
log_analytics_workspace_id = null

 

How to run

Terraform init

Terraform Validate

Terraform Plan

Terraform Apply

 

How traffic flows (what you must configure to actually reach your app)

Front Door receives the client request on the Front Door default hostname.

Front Door forwards the request to the origin host (the NGINX ingress public IP on port 80 in this example).

Front Door sends the Host header you set in app_host_name (example: app.contoso.com).

NGINX matches the Kubernetes Ingress rule for that host and routes to the demo service.

If you do not plan to use a real DNS name yet, you can still test by setting app_host_name to a placeholder domain and sending Host headers in your test client, but production should use a real domain.

Production Hardening Changes you should apply (recommended)

 

Use HTTPS from Front Door to NGINX

Terminate TLS at NGINX with a certificate (for App_Host_Name)

Change Front Door Origin Forwarding to HttpsOnly

Set certificate_name_check_enabled to True when your Origin is a DNS Name (not an IP)

For strict TLS Validation, use a DNS Name on the Origin (for example, a DNS A Record to the Ingress IP) and set Origin_Host_Header to that DNS Name or the Application Host Name, Depending on your TLS and Ingress Configuration

 

Restrict Direct Access to the Ingress Public IP

If you must stay Public, use NGINX allowlist rules and WAF, and consider Azure Firewall or other Perimeter Controls

If you can move to Private Origins, use Front Door Premium Design Patterns (Private Link / Private Ingress Exposure) depending on your Architecture Constraints

  1. Health Probe Endpoint

1.      Ensure /Healthz Responds quickly and Consistently

2.      Prefer a dedicated Service Endpoint that does not depend on Slow Downstream Dependencies

  1. If you want, I can also provide the “Production” variant for AKS where
  2. NGINX Ingress is Internal Only
  3. Front Door Premium reaches the origin through a Private Connectivity Pattern
  4. End-to-end TLS is enforced with origin Certificate Validation
  5. Origin access is fully Locked Down so the Application is not reachable except through Front Door

 

If you would like to explore this topic in greater depth, see my book Azure Front Door Design Security Performance and Global Application Delivery, where the subject is covered in much greater detail. The guide expands on the concepts discussed in this article with deeper architectural explanations, service capabilities, and step-by-step implementation using Azure Portal, Azure CLI, Terraform, and Bicep. It also includes real-world deployment, configuration, and troubleshooting scenarios designed for IT professionals, administrators, and cloud architects. All of my books include detailed architectural diagrams and practical deployment examples using PowerShell, Azure CLI, Terraform, and Bicep.

0 comments

Leave a comment

Please note, comments need to be approved before they are published.