Quick take — A production-ready Terraform module for Azure Linux Web App on azurerm ~> 4.0: HTTPS-only, system-assigned identity, health checks, app settings, slots and VNet integration baked in. 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 "app_service" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-app-service?ref=v1.0.0"
name = "..." # Globally unique web app name; becomes `<name>.azurewebs…
resource_group_name = "..." # Resource group for the app and plan.
location = "..." # Azure region, e.g. `centralindia`.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure App Service (Web App) is the platform-as-a-service way to run web applications and APIs on Azure without managing the underlying VMs, OS patching, or load balancers. On Linux it runs your code from a built-in runtime stack (Node, .NET, Python, Java, PHP, Go) or straight from a container image, and you pay for an App Service Plan that defines the compute SKU shared across one or more apps.
The raw azurerm_linux_web_app resource is deceptively large: it has nested site_config, application_stack, auth_settings_v2, logs, sticky_settings, and identity blocks, and getting production defaults right (HTTPS-only, minimum TLS 1.2, FTPS disabled, health checks, always_on) is easy to forget and easy to drift on. Wrapping it in a reusable module means every web app your team deploys is secure-by-default, consistently named, wired to a managed identity, and emits the same outputs (hostname, identity principal ID) that downstream Key Vault access policies and DNS records depend on. You write five variables; the module enforces the other thirty decisions.
When to use it
- You are deploying stateless web apps or REST/GraphQL APIs (Node, .NET, Python, Java) and want PaaS instead of running containers on AKS or VMs.
- You need managed TLS, autoscale, deployment slots, and built-in CI/CD hooks without operating Kubernetes.
- You want a system-assigned managed identity so the app can pull secrets from Key Vault or connect to Azure SQL / Storage without connection-string passwords.
- You are standardizing many small-to-medium workloads across teams and want identical security posture and tagging every time.
- Reach for Container Apps instead if you need fine-grained scale-to-zero or Dapr; reach for AKS if you need full orchestration. For most line-of-business web tiers, App Service is the cheaper, lower-ops choice.
Module structure
terraform-module-azure-app-service/
├── versions.tf # provider + Terraform version pins
├── main.tf # azurerm_linux_web_app + optional plan, slot, vnet integration
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id, name, hostname, identity principal id, plan id
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
locals {
# Merge caller settings with a couple of safe defaults.
app_settings = merge(
{
"WEBSITE_HTTPLOGGING_RETENTION_DAYS" = "7"
},
var.app_settings
)
tags = merge(
{
managed_by = "terraform"
module = "terraform-module-azure-app-service"
},
var.tags
)
}
# Optionally create the App Service Plan, or reuse an existing one by ID.
resource "azurerm_service_plan" "this" {
count = var.create_service_plan ? 1 : 0
name = coalesce(var.service_plan_name, "asp-${var.name}")
resource_group_name = var.resource_group_name
location = var.location
os_type = "Linux"
sku_name = var.sku_name
# Zone balancing requires a Premium v2/v3 SKU and >= 2 workers.
zone_balancing_enabled = var.zone_balancing_enabled
worker_count = var.worker_count
tags = local.tags
}
resource "azurerm_linux_web_app" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
service_plan_id = var.create_service_plan ? azurerm_service_plan.this[0].id : var.service_plan_id
https_only = true
public_network_access_enabled = var.public_network_access_enabled
virtual_network_subnet_id = var.vnet_integration_subnet_id
site_config {
always_on = var.always_on
http2_enabled = true
ftps_state = "Disabled"
minimum_tls_version = "1.2"
health_check_path = var.health_check_path
health_check_eviction_time_in_min = var.health_check_path == null ? null : 5
worker_count = var.worker_count
vnet_route_all_enabled = var.vnet_integration_subnet_id != null
application_stack {
# Exactly one of these should be set by the caller.
node_version = var.runtime_stack == "node" ? var.runtime_version : null
python_version = var.runtime_stack == "python" ? var.runtime_version : null
dotnet_version = var.runtime_stack == "dotnet" ? var.runtime_version : null
java_version = var.runtime_stack == "java" ? var.runtime_version : null
docker_image_name = var.runtime_stack == "docker" ? var.docker_image_name : null
docker_registry_url = var.runtime_stack == "docker" ? var.docker_registry_url : null
}
dynamic "ip_restriction" {
for_each = var.allowed_ip_cidrs
content {
name = "allow-${ip_restriction.key}"
action = "Allow"
priority = 100 + ip_restriction.key
ip_address = ip_restriction.value
}
}
}
identity {
type = "SystemAssigned"
}
app_settings = local.app_settings
logs {
http_logs {
file_system {
retention_in_days = 7
retention_in_mb = 35
}
}
application_logs {
file_system_level = "Information"
}
}
lifecycle {
# App settings injected by deployment slots / CI should not cause drift.
ignore_changes = [
app_settings["WEBSITE_RUN_FROM_PACKAGE"],
]
}
tags = local.tags
}
# Optional staging slot for blue/green deployments (Standard SKU and up).
resource "azurerm_linux_web_app_slot" "staging" {
count = var.enable_staging_slot ? 1 : 0
name = "staging"
app_service_id = azurerm_linux_web_app.this.id
https_only = true
virtual_network_subnet_id = var.vnet_integration_subnet_id
site_config {
always_on = var.always_on
minimum_tls_version = "1.2"
ftps_state = "Disabled"
health_check_path = var.health_check_path
application_stack {
node_version = var.runtime_stack == "node" ? var.runtime_version : null
python_version = var.runtime_stack == "python" ? var.runtime_version : null
dotnet_version = var.runtime_stack == "dotnet" ? var.runtime_version : null
java_version = var.runtime_stack == "java" ? var.runtime_version : null
docker_image_name = var.runtime_stack == "docker" ? var.docker_image_name : null
docker_registry_url = var.runtime_stack == "docker" ? var.docker_registry_url : null
}
}
identity {
type = "SystemAssigned"
}
app_settings = local.app_settings
tags = local.tags
}
variables.tf
variable "name" {
type = string
description = "Globally unique name of the web app (becomes <name>.azurewebsites.net)."
validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$", var.name))
error_message = "Name must be 2-60 chars, lowercase alphanumeric or hyphen, not starting/ending with a hyphen."
}
}
variable "resource_group_name" {
type = string
description = "Resource group to deploy the web app (and plan) into."
}
variable "location" {
type = string
description = "Azure region, e.g. 'centralindia' or 'eastus'."
}
variable "create_service_plan" {
type = bool
description = "Create a dedicated App Service Plan. Set false to reuse one via service_plan_id."
default = true
}
variable "service_plan_id" {
type = string
description = "Existing Linux App Service Plan ID to reuse (required when create_service_plan = false)."
default = null
}
variable "service_plan_name" {
type = string
description = "Name for the created plan. Defaults to 'asp-<name>'."
default = null
}
variable "sku_name" {
type = string
description = "App Service Plan SKU. Use B1/B2 for dev, P1v3+ for production (zone redundancy needs Pv3)."
default = "P1v3"
validation {
condition = contains(
["B1", "B2", "B3", "S1", "S2", "S3", "P0v3", "P1v3", "P2v3", "P3v3"],
var.sku_name
)
error_message = "sku_name must be one of B1-B3, S1-S3, or P0v3-P3v3."
}
}
variable "runtime_stack" {
type = string
description = "Runtime stack: node, python, dotnet, java, or docker."
default = "node"
validation {
condition = contains(["node", "python", "dotnet", "java", "docker"], var.runtime_stack)
error_message = "runtime_stack must be node, python, dotnet, java, or docker."
}
}
variable "runtime_version" {
type = string
description = "Runtime version string, e.g. '20-lts' for node, '3.12' for python, '8.0' for dotnet. Ignored for docker."
default = "20-lts"
}
variable "docker_image_name" {
type = string
description = "Container image and tag when runtime_stack = docker, e.g. 'myapp:1.4.2'."
default = null
}
variable "docker_registry_url" {
type = string
description = "Container registry URL when runtime_stack = docker, e.g. 'https://myacr.azurecr.io'."
default = null
}
variable "always_on" {
type = bool
description = "Keep the app warm. Must be false on Free/Basic-shared tiers; true is recommended for production."
default = true
}
variable "health_check_path" {
type = string
description = "Path App Service pings to detect unhealthy instances, e.g. '/healthz'. Null disables it."
default = null
}
variable "worker_count" {
type = number
description = "Number of instances (workers) for the plan / app scale-out."
default = 1
validation {
condition = var.worker_count >= 1 && var.worker_count <= 30
error_message = "worker_count must be between 1 and 30."
}
}
variable "zone_balancing_enabled" {
type = bool
description = "Spread instances across availability zones. Requires Pv3 SKU and worker_count >= 2."
default = false
}
variable "public_network_access_enabled" {
type = bool
description = "Allow public internet access to the app. Set false when fronting with Private Endpoint / App Gateway."
default = true
}
variable "vnet_integration_subnet_id" {
type = string
description = "Delegated subnet ID for regional VNet integration (outbound). Null disables integration."
default = null
}
variable "allowed_ip_cidrs" {
type = list(string)
description = "Inbound IP allow-list (CIDRs). Empty list leaves access open (subject to public_network_access)."
default = []
}
variable "enable_staging_slot" {
type = bool
description = "Create a 'staging' deployment slot for blue/green swaps. Requires Standard SKU or higher."
default = false
}
variable "app_settings" {
type = map(string)
description = "Application settings (environment variables) merged into the app."
default = {}
}
variable "tags" {
type = map(string)
description = "Tags merged onto the plan, app, and slot."
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the Linux Web App."
value = azurerm_linux_web_app.this.id
}
output "name" {
description = "Name of the Linux Web App."
value = azurerm_linux_web_app.this.name
}
output "default_hostname" {
description = "Default hostname (e.g. myapp.azurewebsites.net) without scheme."
value = azurerm_linux_web_app.this.default_hostname
}
output "default_url" {
description = "Full HTTPS URL of the web app."
value = "https://${azurerm_linux_web_app.this.default_hostname}"
}
output "identity_principal_id" {
description = "Principal (object) ID of the system-assigned managed identity, for Key Vault / RBAC grants."
value = azurerm_linux_web_app.this.identity[0].principal_id
}
output "outbound_ip_addresses" {
description = "Comma-separated list of possible outbound IPs (for firewall allow-lists on databases)."
value = azurerm_linux_web_app.this.outbound_ip_addresses
}
output "service_plan_id" {
description = "ID of the App Service Plan in use (created or reused)."
value = var.create_service_plan ? azurerm_service_plan.this[0].id : var.service_plan_id
}
output "staging_slot_hostname" {
description = "Default hostname of the staging slot, or null when disabled."
value = var.enable_staging_slot ? azurerm_linux_web_app_slot.staging[0].default_hostname : null
}
How to use it
resource "azurerm_resource_group" "app" {
name = "rg-kloudvin-prod"
location = "centralindia"
}
module "app_service_web_app_api" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-app-service?ref=v1.0.0"
name = "kloudvin-api-prod"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
sku_name = "P1v3"
runtime_stack = "node"
runtime_version = "20-lts"
always_on = true
health_check_path = "/healthz"
worker_count = 2
zone_balancing_enabled = true
enable_staging_slot = true
# Outbound integration into the spoke VNet so it can reach private SQL.
vnet_integration_subnet_id = azurerm_subnet.app_integration.id
public_network_access_enabled = true
app_settings = {
"NODE_ENV" = "production"
"KEY_VAULT_URI" = azurerm_key_vault.app.vault_uri
"APPLICATIONINSIGHTS_CONNECTION_STRING" = azurerm_application_insights.app.connection_string
}
tags = {
environment = "prod"
cost_center = "platform"
}
}
# Downstream: grant the app's managed identity read access to Key Vault secrets,
# using the identity principal ID output by the module.
resource "azurerm_role_assignment" "app_kv_secrets" {
scope = azurerm_key_vault.app.id
role_definition_name = "Key Vault Secrets User"
principal_id = module.app_service_web_app_api.identity_principal_id
}
# Downstream: point a custom DNS CNAME at the app's default hostname.
resource "azurerm_dns_cname_record" "api" {
name = "api"
zone_name = "kloudvin.com"
resource_group_name = "rg-dns"
ttl = 300
record = module.app_service_web_app_api.default_hostname
}
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/app_service/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-app-service?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/app_service && 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 | Globally unique web app name; becomes <name>.azurewebsites.net. |
| resource_group_name | string | — | yes | Resource group for the app and plan. |
| location | string | — | yes | Azure region, e.g. centralindia. |
| create_service_plan | bool | true |
no | Create a dedicated plan, or reuse one via service_plan_id. |
| service_plan_id | string | null |
no | Existing Linux plan ID (required when create_service_plan = false). |
| service_plan_name | string | null |
no | Name for the created plan; defaults to asp-<name>. |
| sku_name | string | "P1v3" |
no | Plan SKU; B1–B3, S1–S3, or P0v3–P3v3. |
| runtime_stack | string | "node" |
no | One of node, python, dotnet, java, docker. |
| runtime_version | string | "20-lts" |
no | Runtime version (ignored for docker). |
| docker_image_name | string | null |
no | Image and tag when runtime_stack = docker. |
| docker_registry_url | string | null |
no | Registry URL when runtime_stack = docker. |
| always_on | bool | true |
no | Keep the app warm; must be false on shared tiers. |
| health_check_path | string | null |
no | Path for instance health probes; null disables. |
| worker_count | number | 1 |
no | Instance count (1–30). |
| zone_balancing_enabled | bool | false |
no | Spread instances across zones (needs Pv3 + ≥2 workers). |
| public_network_access_enabled | bool | true |
no | Allow public access; set false behind Private Endpoint. |
| vnet_integration_subnet_id | string | null |
no | Delegated subnet ID for outbound VNet integration. |
| allowed_ip_cidrs | list(string) | [] |
no | Inbound IP allow-list (CIDRs). |
| enable_staging_slot | bool | false |
no | Create a staging slot (needs Standard SKU+). |
| app_settings | map(string) | {} |
no | Application settings / environment variables. |
| tags | map(string) | {} |
no | Tags merged onto plan, app, and slot. |
Outputs
| Name | Description |
|---|---|
| id | Resource ID of the Linux Web App. |
| name | Name of the Linux Web App. |
| default_hostname | Default hostname without scheme (e.g. myapp.azurewebsites.net). |
| default_url | Full HTTPS URL of the app. |
| identity_principal_id | Principal ID of the system-assigned managed identity, for Key Vault / RBAC grants. |
| outbound_ip_addresses | Comma-separated outbound IPs for database firewall allow-lists. |
| service_plan_id | ID of the App Service Plan in use (created or reused). |
| staging_slot_hostname | Hostname of the staging slot, or null when disabled. |
Enterprise scenario
A retail platform team runs about forty internal line-of-business APIs and admin portals on Azure. They consume this module from their landing-zone pipeline so every app lands as P1v3 with zone balancing across two workers, HTTPS-only with TLS 1.2, a /healthz probe, and VNet integration into the shared services spoke. The module’s identity_principal_id output is fed straight into Key Vault Secrets User role assignments, so no API ever ships a database password in an app setting; combined with the staging slot they swap releases with zero downtime during business hours.
Best practices
- Lock down transport and management surface: the module already forces
https_only,minimum_tls_version = "1.2", andftps_state = "Disabled". Keep them — disabled FTPS and enforced HTTPS are the two most common App Service audit findings. - Use the managed identity, not connection strings: wire
identity_principal_idto Key Vault and Azure SQL via RBAC so secrets never live inapp_settings. Reference Key Vault secrets with@Microsoft.KeyVault(...)syntax when you must inject them. - Right-size the SKU for cost: dev/test on B1/B2 (no
always_on, no zone balancing); only pay for P1v3+ where you need autoscale, slots, and zone redundancy. One plan can host several small apps — setcreate_service_plan = falseand shareservice_plan_idto cut spend. - Always configure a health check path in production so Azure evicts unhealthy instances behind the load balancer instead of serving 5xx; pair it with
worker_count >= 2so there is somewhere to fail over to. - Deploy via slots, not in place: enable the staging slot, deploy and warm there, then swap — slot swaps are near-instant and instantly reversible, unlike a direct production deploy.
- Name and tag consistently: keep the
<workload>-<env>web app name andasp-<name>plan convention so the globally-unique hostname is predictable, and rely on the mergedmanaged_by/environmenttags for cost allocation and governance.