IaC Azure

Terraform Module: Azure Static Web App — globally-distributed JAMstack hosting with managed APIs and zero-trust app settings

Quick take — A production-grade Terraform module for azurerm_static_web_app on azurerm ~> 4.0: SKU-aware config, custom domains, app settings, and managed Functions backends wired up for CI/CD. 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 "static_web_app" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-static-web-app?ref=v1.0.0"

  name                = "..."  # Static Web App name; must be globally unique in `*.azur…
  resource_group_name = "..."  # Resource group to deploy into.
  location            = "..."  # Control-plane region (westus2, centralus, eastus2, west…
}

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

What this module is

Azure Static Web App (SWA) is a managed hosting service purpose-built for modern web frontends — React, Angular, Vue, Svelte, Astro, or any pre-rendered static output — fronted by a global CDN and paired with a serverless managed Functions backend for /api routes. It bundles things that are otherwise three or four separate Azure resources: global content distribution, free auto-renewing TLS, staging environments per pull request, integrated authentication (EasyAuth via staticwebapp.config.json), and a built-in reverse proxy that stitches your static assets and API together under one origin so you never fight CORS.

The reason to wrap azurerm_static_web_app in a reusable module is that the interesting configuration lives in the surrounding resources, not the site itself. A bare SWA resource is three lines. A production SWA needs a custom domain with the right validation type, a curated set of application settings (which on SWA double as the environment variables injected into the managed Functions runtime), the correct SKU to unlock private endpoints and SLAs, and consistent tagging for cost allocation. This module captures that whole opinionated bundle — azurerm_static_web_app, azurerm_static_web_app_custom_domain, and the app-settings wiring — behind a handful of validated variables so every team ships a SWA that looks the same.

When to use it

Module structure

terraform-module-azure-static-web-app/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # azurerm_static_web_app + custom domain + app settings
├── variables.tf     # validated input surface
└── outputs.tf       # id/name, default hostname, deployment token, API key

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

resource "azurerm_static_web_app" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location

  sku_tier = var.sku_tier
  sku_size = var.sku_size

  # Preview/staging environments per PR. Disable on Free or to lock to prod only.
  preview_environments_enabled = var.preview_environments_enabled

  # On SWA, app settings are also the env vars injected into the managed Functions
  # runtime. Keep secrets out of here; reference Key Vault from the Functions code
  # or pass Key Vault references as values.
  app_settings = var.app_settings

  # Required when using private endpoints (Standard SKU only).
  public_network_access_enabled = var.public_network_access_enabled

  dynamic "identity" {
    for_each = var.identity_type == null ? [] : [1]
    content {
      type         = var.identity_type
      identity_ids = var.identity_type == "UserAssigned" ? var.identity_ids : null
    }
  }

  tags = var.tags
}

# Custom domains. Apex domains must use "dns-txt-token" validation; subdomains
# (www, app, docs) use "cname-delegation". The module validates this pairing.
resource "azurerm_static_web_app_custom_domain" "this" {
  for_each = { for d in var.custom_domains : d.domain_name => d }

  static_web_app_id = azurerm_static_web_app.this.id
  domain_name       = each.value.domain_name
  validation_type   = each.value.validation_type
}

variables.tf

variable "name" {
  type        = string
  description = "Name of the Static Web App. Must be globally unique within the *.azurestaticapps.net namespace."

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

variable "resource_group_name" {
  type        = string
  description = "Name of the resource group in which to create the Static Web App."
}

variable "location" {
  type        = string
  description = "Azure region for the control-plane resource. SWA is only offered in a subset of regions (e.g. westus2, centralus, eastus2, westeurope, eastasia). Content is still served globally from edge regardless of this value."

  validation {
    condition = contains(
      ["westus2", "centralus", "eastus2", "westeurope", "eastasia"],
      lower(var.location)
    )
    error_message = "Static Web App is only available in westus2, centralus, eastus2, westeurope, or eastasia."
  }
}

variable "sku_tier" {
  type        = string
  default     = "Standard"
  description = "SKU tier. 'Free' for hobby/dev (no SLA, no custom auth, no private endpoints); 'Standard' for production (SLA, private endpoints, bring-your-own Functions, more custom domains)."

  validation {
    condition     = contains(["Free", "Standard"], var.sku_tier)
    error_message = "sku_tier must be either 'Free' or 'Standard'."
  }
}

variable "sku_size" {
  type        = string
  default     = "Standard"
  description = "SKU size. For SWA this mirrors sku_tier ('Free' or 'Standard')."

  validation {
    condition     = contains(["Free", "Standard"], var.sku_size)
    error_message = "sku_size must be either 'Free' or 'Standard'."
  }
}

variable "preview_environments_enabled" {
  type        = bool
  default     = true
  description = "Enable per-pull-request preview/staging environments. Requires the Standard SKU."
}

variable "public_network_access_enabled" {
  type        = bool
  default     = true
  description = "Whether the app is reachable over the public internet. Set to false when exposing the app exclusively via a private endpoint (Standard SKU only)."
}

variable "app_settings" {
  type        = map(string)
  default     = {}
  description = "Key/value application settings. On SWA these are injected as environment variables into the managed Functions runtime. Do not store raw secrets here — use Key Vault references."
}

variable "identity_type" {
  type        = string
  default     = null
  description = "Managed identity type: 'SystemAssigned', 'UserAssigned', 'SystemAssigned, UserAssigned', or null to disable. Use this so the managed Functions can pull Key Vault secrets without credentials."

  validation {
    condition = var.identity_type == null || contains(
      ["SystemAssigned", "UserAssigned", "SystemAssigned, UserAssigned"],
      coalesce(var.identity_type, "SystemAssigned")
    )
    error_message = "identity_type must be one of 'SystemAssigned', 'UserAssigned', 'SystemAssigned, UserAssigned', or null."
  }
}

variable "identity_ids" {
  type        = list(string)
  default     = []
  description = "Resource IDs of user-assigned managed identities. Required when identity_type includes 'UserAssigned'."
}

variable "custom_domains" {
  type = list(object({
    domain_name     = string
    validation_type = string
  }))
  default     = []
  description = "Custom domains to bind. Use validation_type 'cname-delegation' for subdomains (www, app) and 'dns-txt-token' for apex/root domains."

  validation {
    condition = alltrue([
      for d in var.custom_domains :
      contains(["cname-delegation", "dns-txt-token"], d.validation_type)
    ])
    error_message = "Each custom domain validation_type must be 'cname-delegation' or 'dns-txt-token'."
  }
}

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Tags applied to the Static Web App for cost allocation and governance."
}

outputs.tf

output "id" {
  description = "The resource ID of the Static Web App."
  value       = azurerm_static_web_app.this.id
}

output "name" {
  description = "The name of the Static Web App."
  value       = azurerm_static_web_app.this.name
}

output "default_host_name" {
  description = "The auto-generated *.azurestaticapps.net hostname serving the site."
  value       = azurerm_static_web_app.this.default_host_name
}

output "api_key" {
  description = "The deployment API key (token) used by CI/CD (e.g. the Azure/static-web-apps-deploy action) to publish builds."
  value       = azurerm_static_web_app.this.api_key
  sensitive   = true
}

output "custom_domain_names" {
  description = "Map of configured custom domain names to their resource IDs."
  value       = { for k, d in azurerm_static_web_app_custom_domain.this : k => d.id }
}

output "principal_id" {
  description = "The principal ID of the system-assigned identity, if enabled (for Key Vault access policy grants)."
  value       = try(azurerm_static_web_app.this.identity[0].principal_id, null)
}

How to use it

module "static_web_app" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-static-web-app?ref=v1.0.0"

  name                = "swa-kloudvin-prod"
  resource_group_name = azurerm_resource_group.web.name
  location            = "westeurope"

  sku_tier = "Standard"
  sku_size = "Standard"

  preview_environments_enabled = true

  # System-assigned identity so the managed Functions can read Key Vault.
  identity_type = "SystemAssigned"

  # App settings double as Functions env vars. Reference Key Vault, never raw secrets.
  app_settings = {
    "API_BASE_URL"      = "https://api.kloudvin.com"
    "FEATURE_NEW_NAV"   = "true"
    "DATABASE_PASSWORD" = "@Microsoft.KeyVault(SecretUri=https://kv-kloudvin.vault.azure.net/secrets/db-password/)"
  }

  custom_domains = [
    { domain_name = "www.kloudvin.com", validation_type = "cname-delegation" },
    { domain_name = "kloudvin.com",     validation_type = "dns-txt-token" },
  ]

  tags = {
    environment = "production"
    workload    = "kloudvin-frontend"
    cost_center = "CC-1042"
  }
}

# Downstream: grant the SWA's managed identity read access to Key Vault secrets
# so the @Microsoft.KeyVault references above resolve at runtime.
resource "azurerm_role_assignment" "swa_kv_secrets" {
  scope                = azurerm_key_vault.this.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = module.static_web_app.principal_id
}

# Downstream: feed the deployment token into the Azure DevOps / GitHub pipeline.
output "swa_deploy_token" {
  value     = module.static_web_app.api_key
  sensitive = true
}

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/static_web_app/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
}

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

cd live/prod/static_web_app && 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 n/a Yes Static Web App name; must be globally unique in *.azurestaticapps.net. 3–40 chars, alphanumeric/hyphen.
resource_group_name string n/a Yes Resource group to deploy into.
location string n/a Yes Control-plane region (westus2, centralus, eastus2, westeurope, eastasia). Content is served globally regardless.
sku_tier string "Standard" No Free or Standard. Standard unlocks SLA, private endpoints, and more custom domains.
sku_size string "Standard" No Free or Standard; mirrors sku_tier.
preview_environments_enabled bool true No Per-PR staging environments (Standard SKU).
public_network_access_enabled bool true No Set false to expose only via private endpoint (Standard SKU).
app_settings map(string) {} No Key/value settings injected as Functions env vars. Use Key Vault references for secrets.
identity_type string null No SystemAssigned, UserAssigned, SystemAssigned, UserAssigned, or null.
identity_ids list(string) [] No User-assigned identity IDs; required when identity_type includes UserAssigned.
custom_domains list(object) [] No Domains to bind; each { domain_name, validation_type }. Use cname-delegation for subdomains, dns-txt-token for apex.
tags map(string) {} No Resource tags for cost allocation and governance.

Outputs

Name Description
id The resource ID of the Static Web App.
name The name of the Static Web App.
default_host_name The auto-generated *.azurestaticapps.net hostname.
api_key The deployment API key (token) consumed by CI/CD to publish builds (sensitive).
custom_domain_names Map of configured custom domain names to their resource IDs.
principal_id Principal ID of the system-assigned identity, if enabled (for Key Vault grants).

Enterprise scenario

A retail group runs a customer-facing Astro storefront with an /api checkout layer implemented as managed Functions. Platform engineering publishes this module at v1.0.0 and each of the 14 brand teams instantiates it with its own name, apex + www custom domains, and a SystemAssigned identity that reads Stripe and database secrets from a per-brand Key Vault via @Microsoft.KeyVault references. Because Standard-SKU preview environments are enabled, every pull request gets an isolated staging URL that QA validates before merge, and the deployment token flows straight from the module output into each brand’s Azure DevOps pipeline — no secret is ever typed into a YAML file.

Best practices

TerraformAzureStatic Web AppModuleIaC
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