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
- You are hosting a static or pre-rendered frontend (SPA, SSG output, documentation site) and want global distribution without standing up a CDN profile, storage account, and Front Door yourself.
- You want API routes co-located with the frontend under a single origin, served by managed Azure Functions, without managing a separate Function App, storage account, or App Service plan.
- You need per-PR preview environments out of the box — SWA spins up an isolated staging URL for every pull request on the Standard SKU.
- You want built-in authentication (Microsoft Entra ID, GitHub, custom OIDC) wired through
staticwebapp.config.jsonrather than hand-rolling token validation. - Reach for App Service or Container Apps instead when you need a long-running server process, server-side rendering with a persistent Node runtime, WebSockets, or background workers — SWA’s managed Functions are short-lived and HTTP-triggered only.
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 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/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
- Never put raw secrets in
app_settings. They are visible in the portal and in plan output, and on SWA they are injected directly into the Functions runtime. Enable a managed identity and use@Microsoft.KeyVault(SecretUri=...)references so rotation happens in Key Vault, not in Terraform state. - Match
validation_typeto the domain shape. Apex/root domains (kloudvin.com) requiredns-txt-token; subdomains (www,app,docs) usecname-delegation. Getting this wrong leaves the domain stuck in a perpetual “Validating” state. - Pick the SKU deliberately for cost. The Free tier has no SLA, no enterprise auth, and no private endpoints — fine for docs and spikes, but a production storefront belongs on Standard. Don’t leave dozens of idle Free apps around; they still consume naming and governance overhead.
- Treat
api_keyas a deployment secret. Mark every consuming outputsensitive, pipe it directly into the pipeline’s secret store, and rotate it if it ever lands in a log. It grants full publish rights to the site. - Lock down the network for internal apps. For intranet or B2B portals, set
public_network_access_enabled = falseon Standard and front the app with a private endpoint so it is unreachable from the public internet. - Standardise naming and tags through the module. Use a consistent
swa-<workload>-<env>convention and enforceenvironment,workload, andcost_centertags so finance can attribute global egress and Functions execution back to the owning team.