IaC Azure

Terraform Module: Azure Function App — serverless compute with storage, plan and identity wired in

Quick take — A reusable hashicorp/azurerm ~> 4.0 module for azurerm_linux_function_app: its backing storage account, service plan, Application Insights, managed identity and runtime stack, all var-driven and validated. 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 "function_app" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-function-app?ref=v1.0.0"

  name                 = "..."  # Globally unique Function App name (2-60 chars, lowercas…
  resource_group_name  = "..."  # Resource group for the app and its dependencies.
  location             = "..."  # Azure region.
  storage_account_name = "..."  # Globally unique backing storage account name (3-24 lowe…
  service_plan_name    = "..."  # Name of the plan to create.
}

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

What this module is

azurerm_linux_function_app is the resource behind Azure Functions on Linux — event-driven serverless compute that runs your code in response to HTTP requests, queue messages, timers, blob events, and dozens of other triggers. The catch is that a Function App is never a standalone resource: it requires a backing storage account (for triggers, the function key store, and the deployment package), it must live on a azurerm_service_plan (Consumption Y1, Elastic Premium EP*, or a dedicated App Service plan), and in any real deployment it also wants Application Insights for traces, a managed identity for keyless access to Key Vault and Storage, and a correctly-pinned runtime stack (dotnet-isolated, node, python, java, powershell). Get the WEBSITE_RUN_FROM_PACKAGE, FUNCTIONS_EXTENSION_VERSION, or storage connection wiring wrong and the app silently fails to start with a blank Functions list.

This module wraps azurerm_linux_function_app and the two resources it cannot live without — the storage account and the service plan — plus an optional Application Insights component, into one opinionated, var-driven unit. It bakes in the non-negotiables: HTTPS-only, a minimum TLS version, FTPS disabled, a SystemAssigned managed identity by default, the ~4 extension version, and the Application Insights connection string injected automatically. You hand it a name, a region, and a runtime; it stamps out an identical, hardened Function App every time and emits the outputs (default_hostname, id, identity principal ID, storage account name) that your DNS, role assignments, and CI deploy steps consume.

When to use it

Module structure

terraform-module-azure-function-app/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # storage account + service plan + app insights + function app
├── variables.tf     # all inputs with validation
└── outputs.tf       # id, name, hostname, identity, storage, plan

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Consumption (Y1) and Elastic Premium (EP*) plans cannot use always_on.
  is_consumption     = var.sku_name == "Y1"
  is_elastic_premium = startswith(var.sku_name, "EP")
  always_on          = (local.is_consumption || local.is_elastic_premium) ? false : var.always_on

  # Inject the App Insights connection string only when we created the component.
  insights_settings = var.application_insights_enabled ? {
    "APPLICATIONINSIGHTS_CONNECTION_STRING" = azurerm_application_insights.this[0].connection_string
    "APPLICATIONINSIGHTS_AGENT_EXTENSION_VERSION" = "~3"
  } : {}

  app_settings = merge(local.insights_settings, var.app_settings)

  common_tags = merge(var.tags, { managed_by = "terraform" })
}

# Backing storage account — required by every Function App for trigger state,
# the function key store, and (optionally) the run-from-package deployment blob.
resource "azurerm_storage_account" "this" {
  name                     = var.storage_account_name
  resource_group_name      = var.resource_group_name
  location                 = var.location
  account_tier             = "Standard"
  account_replication_type = var.storage_replication_type
  min_tls_version          = "TLS1_2"

  # Keep keys but block anonymous blob access; the app can use the identity instead.
  shared_access_key_enabled       = var.storage_shared_access_key_enabled
  allow_nested_items_to_be_public = false
  public_network_access_enabled   = var.storage_public_network_access_enabled

  tags = local.common_tags
}

# The plan the Function App runs on. Y1 = Consumption, EP1-3 = Elastic Premium,
# P*v3 = dedicated App Service. Drives scaling, cold-start and VNet behaviour.
resource "azurerm_service_plan" "this" {
  name                         = var.service_plan_name
  resource_group_name          = var.resource_group_name
  location                     = var.location
  os_type                      = "Linux"
  sku_name                     = var.sku_name
  maximum_elastic_worker_count = local.is_elastic_premium ? var.maximum_elastic_worker_count : null
  zone_balancing_enabled       = var.zone_balancing_enabled

  tags = local.common_tags
}

# Optional Application Insights for distributed traces, live metrics and failures.
resource "azurerm_application_insights" "this" {
  count = var.application_insights_enabled ? 1 : 0

  name                = coalesce(var.application_insights_name, "${var.name}-ai")
  resource_group_name = var.resource_group_name
  location            = var.location
  application_type    = "web"
  workspace_id        = var.log_analytics_workspace_id
  retention_in_days   = var.application_insights_retention_days

  tags = local.common_tags
}

resource "azurerm_linux_function_app" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location
  service_plan_id     = azurerm_service_plan.this.id

  storage_account_name          = azurerm_storage_account.this.name
  storage_account_access_key    = var.storage_shared_access_key_enabled ? azurerm_storage_account.this.primary_access_key : null
  storage_uses_managed_identity = var.storage_shared_access_key_enabled ? null : true

  functions_extension_version  = var.functions_extension_version
  https_only                   = true
  public_network_access_enabled = var.public_network_access_enabled
  virtual_network_subnet_id    = var.virtual_network_subnet_id

  app_settings = local.app_settings

  site_config {
    always_on                              = local.always_on
    ftps_state                             = "Disabled"
    minimum_tls_version                    = var.minimum_tls_version
    http2_enabled                          = true
    application_insights_connection_string = var.application_insights_enabled ? azurerm_application_insights.this[0].connection_string : null
    use_32_bit_worker                      = false
    vnet_route_all_enabled                 = var.virtual_network_subnet_id != null

    # Pin exactly one runtime stack. Empty strings are ignored by the provider.
    application_stack {
      dotnet_version              = var.runtime_stack == "dotnet" ? var.runtime_version : null
      use_dotnet_isolated_runtime = var.runtime_stack == "dotnet-isolated" ? true : null
      node_version                = var.runtime_stack == "node" ? var.runtime_version : null
      python_version              = var.runtime_stack == "python" ? var.runtime_version : null
      java_version                = var.runtime_stack == "java" ? var.runtime_version : null
      powershell_core_version     = var.runtime_stack == "powershell" ? var.runtime_version : null
    }

    # Lock down inbound traffic to an allow-list when provided.
    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
      }
    }

    cors {
      allowed_origins     = var.cors_allowed_origins
      support_credentials = var.cors_support_credentials
    }
  }

  # Keyless by default: a system-assigned identity for Key Vault / Storage access.
  dynamic "identity" {
    for_each = var.identity_type == null ? [] : [1]
    content {
      type         = var.identity_type
      identity_ids = strcontains(var.identity_type, "UserAssigned") ? var.identity_ids : null
    }
  }

  lifecycle {
    # The deploy pipeline owns the package; don't let Terraform fight it.
    ignore_changes = [
      app_settings["WEBSITE_RUN_FROM_PACKAGE"],
    ]
  }

  tags = local.common_tags
}

variables.tf

variable "name" {
  description = "Globally unique Function App name (2-60 chars, lowercase alphanumeric and hyphens)."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9-]{0,58}[a-z0-9]$", var.name))
    error_message = "name must be 2-60 chars: lowercase alphanumeric and hyphens, not starting/ending with a hyphen."
  }
}

variable "resource_group_name" {
  description = "Resource group to create the Function App and its dependencies in."
  type        = string
}

variable "location" {
  description = "Azure region (e.g. centralindia)."
  type        = string
}

variable "storage_account_name" {
  description = "Globally unique storage account name backing the Function App (3-24 lowercase alphanumerics)."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9]{3,24}$", var.storage_account_name))
    error_message = "storage_account_name must be 3-24 lowercase alphanumeric characters, no hyphens."
  }
}

variable "service_plan_name" {
  description = "Name of the App Service / Function plan to create."
  type        = string
}

variable "sku_name" {
  description = "Plan SKU. Y1 = Consumption, EP1/EP2/EP3 = Elastic Premium, P0v3+ = dedicated."
  type        = string
  default     = "Y1"

  validation {
    condition = contains(
      ["Y1", "EP1", "EP2", "EP3", "P0v3", "P1v3", "P2v3", "P3v3"],
      var.sku_name
    )
    error_message = "sku_name must be one of: Y1, EP1, EP2, EP3, P0v3, P1v3, P2v3, P3v3."
  }
}

variable "runtime_stack" {
  description = "Language runtime: dotnet, dotnet-isolated, node, python, java, or powershell."
  type        = string
  default     = "dotnet-isolated"

  validation {
    condition = contains(
      ["dotnet", "dotnet-isolated", "node", "python", "java", "powershell"],
      var.runtime_stack
    )
    error_message = "runtime_stack must be dotnet, dotnet-isolated, node, python, java, or powershell."
  }
}

variable "runtime_version" {
  description = "Runtime version for the chosen stack (e.g. 8.0 for dotnet, 20 for node, 3.11 for python). Ignored for dotnet-isolated."
  type        = string
  default     = "8.0"
}

variable "functions_extension_version" {
  description = "Azure Functions runtime host version. ~4 is current; older versions are out of support."
  type        = string
  default     = "~4"

  validation {
    condition     = can(regex("^~[0-9]+$", var.functions_extension_version))
    error_message = "functions_extension_version must look like ~4."
  }
}

variable "always_on" {
  description = "Keep the app warm. Forced false on Consumption (Y1) and Elastic Premium plans."
  type        = bool
  default     = true
}

variable "minimum_tls_version" {
  description = "Minimum inbound TLS version for the app."
  type        = string
  default     = "1.2"

  validation {
    condition     = contains(["1.2", "1.3"], var.minimum_tls_version)
    error_message = "minimum_tls_version must be 1.2 or 1.3."
  }
}

variable "storage_replication_type" {
  description = "Replication for the backing storage account (LRS, ZRS, GRS, GZRS)."
  type        = string
  default     = "LRS"

  validation {
    condition     = contains(["LRS", "ZRS", "GRS", "GZRS", "RAGRS", "RAGZRS"], var.storage_replication_type)
    error_message = "storage_replication_type must be one of LRS, ZRS, GRS, GZRS, RAGRS, RAGZRS."
  }
}

variable "storage_shared_access_key_enabled" {
  description = "Allow the app to authenticate to storage with the account key. Set false to force managed-identity (keyless) access."
  type        = bool
  default     = true
}

variable "storage_public_network_access_enabled" {
  description = "Allow public network access to the backing storage account."
  type        = bool
  default     = true
}

variable "application_insights_enabled" {
  description = "Create an Application Insights component and wire its connection string into the app."
  type        = bool
  default     = true
}

variable "application_insights_name" {
  description = "Override the Application Insights name. Defaults to <name>-ai."
  type        = string
  default     = null
}

variable "application_insights_retention_days" {
  description = "Application Insights data retention in days."
  type        = number
  default     = 90

  validation {
    condition     = contains([30, 60, 90, 120, 180, 270, 365, 550, 730], var.application_insights_retention_days)
    error_message = "retention must be one of 30, 60, 90, 120, 180, 270, 365, 550, 730 days."
  }
}

variable "log_analytics_workspace_id" {
  description = "Optional Log Analytics workspace ID for workspace-based Application Insights."
  type        = string
  default     = null
}

variable "maximum_elastic_worker_count" {
  description = "Max pre-warmed/elastic workers (Elastic Premium plans only)."
  type        = number
  default     = 20
}

variable "zone_balancing_enabled" {
  description = "Spread plan instances across availability zones (requires a zone-capable SKU and >1 instance)."
  type        = bool
  default     = false
}

variable "public_network_access_enabled" {
  description = "Allow public access to the Function App's HTTP endpoint."
  type        = bool
  default     = true
}

variable "virtual_network_subnet_id" {
  description = "Subnet ID for regional VNet integration (delegated to Microsoft.Web/serverFarms). Null disables it."
  type        = string
  default     = null
}

variable "identity_type" {
  description = "Managed identity: SystemAssigned, UserAssigned, 'SystemAssigned, UserAssigned', or null."
  type        = string
  default     = "SystemAssigned"

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

variable "identity_ids" {
  description = "User-assigned identity resource IDs (required when identity_type includes UserAssigned)."
  type        = list(string)
  default     = []
}

variable "app_settings" {
  description = "Additional app settings merged over the module defaults (e.g. connection strings, feature flags)."
  type        = map(string)
  default     = {}
}

variable "allowed_ip_cidrs" {
  description = "CIDR ranges allowed to reach the app. Empty means allow all (subject to public_network_access)."
  type        = list(string)
  default     = []
}

variable "cors_allowed_origins" {
  description = "Origins allowed by CORS (e.g. https://app.kloudvin.com)."
  type        = list(string)
  default     = []
}

variable "cors_support_credentials" {
  description = "Whether CORS requests may include credentials. Cannot be true with a '*' origin."
  type        = bool
  default     = false
}

variable "tags" {
  description = "Tags applied to the Function App and its dependencies."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Function App."
  value       = azurerm_linux_function_app.this.id
}

output "name" {
  description = "Name of the Function App."
  value       = azurerm_linux_function_app.this.name
}

output "default_hostname" {
  description = "Default hostname (e.g. myfunc.azurewebsites.net) for invoking the app."
  value       = azurerm_linux_function_app.this.default_hostname
}

output "identity_principal_id" {
  description = "Principal ID of the system-assigned identity, for Key Vault / Storage role grants. Null if none."
  value       = try(azurerm_linux_function_app.this.identity[0].principal_id, null)
}

output "outbound_ip_addresses" {
  description = "Comma-separated outbound IP addresses, for downstream firewall allow-lists."
  value       = azurerm_linux_function_app.this.outbound_ip_addresses
}

output "service_plan_id" {
  description = "Resource ID of the service plan the app runs on."
  value       = azurerm_service_plan.this.id
}

output "storage_account_name" {
  description = "Name of the backing storage account."
  value       = azurerm_storage_account.this.name
}

output "application_insights_connection_string" {
  description = "Application Insights connection string (null if Application Insights is disabled)."
  value       = try(azurerm_application_insights.this[0].connection_string, null)
  sensitive   = true
}

output "application_insights_instrumentation_key" {
  description = "Application Insights instrumentation key (null if disabled)."
  value       = try(azurerm_application_insights.this[0].instrumentation_key, null)
  sensitive   = true
}

How to use it

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

  name                 = "kloudvin-prod-orders-func"
  resource_group_name  = azurerm_resource_group.platform.name
  location             = "centralindia"
  storage_account_name = "kvprodordersfunc01"
  service_plan_name    = "kloudvin-prod-orders-plan"

  # Elastic Premium for no cold starts + VNet integration.
  sku_name        = "EP1"
  runtime_stack   = "dotnet-isolated"
  runtime_version = "8.0"

  # Keyless: app reads storage and Key Vault via its managed identity.
  storage_shared_access_key_enabled = false

  # Pin egress and ingress to the platform network.
  virtual_network_subnet_id     = azurerm_subnet.functions.id
  public_network_access_enabled = false

  application_insights_enabled = true
  log_analytics_workspace_id   = azurerm_log_analytics_workspace.platform.id

  app_settings = {
    "ServiceBus__fullyQualifiedNamespace" = "kloudvin-prod.servicebus.windows.net"
    "OrdersDb__connectionString"          = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.orders_db.id})"
  }

  cors_allowed_origins = ["https://app.kloudvin.com"]

  tags = {
    environment = "prod"
    owner       = "orders-team"
  }
}

# Downstream: grant the app's identity keyless access to the backing storage.
resource "azurerm_role_assignment" "func_storage" {
  scope                = "/subscriptions/${var.subscription_id}/resourceGroups/${azurerm_resource_group.platform.name}/providers/Microsoft.Storage/storageAccounts/${module.function_app.storage_account_name}"
  role_definition_name = "Storage Blob Data Owner"
  principal_id         = module.function_app.identity_principal_id
}

# And point a custom domain / CNAME at the default hostname.
resource "azurerm_dns_cname_record" "orders_api" {
  name                = "orders-api"
  zone_name           = azurerm_dns_zone.kloudvin.name
  resource_group_name = azurerm_resource_group.dns.name
  ttl                 = 300
  record              = module.function_app.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 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/function_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-function-app?ref=v1.0.0"
}

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

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

cd live/prod/function_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 Yes Globally unique Function App name (2-60 chars, lowercase alphanumeric + hyphens).
resource_group_name string Yes Resource group for the app and its dependencies.
location string Yes Azure region.
storage_account_name string Yes Globally unique backing storage account name (3-24 lowercase alphanumerics).
service_plan_name string Yes Name of the plan to create.
sku_name string "Y1" No Y1 (Consumption), EP1-3 (Elastic Premium), P0v3-P3v3 (dedicated).
runtime_stack string "dotnet-isolated" No dotnet, dotnet-isolated, node, python, java, or powershell.
runtime_version string "8.0" No Version for the chosen stack (ignored for dotnet-isolated).
functions_extension_version string "~4" No Functions host version (~4 current).
always_on bool true No Keep the app warm. Forced false on Y1 and EP*.
minimum_tls_version string "1.2" No Minimum inbound TLS version (1.2 or 1.3).
storage_replication_type string "LRS" No Backing storage replication (LRS/ZRS/GRS/GZRS/RAGRS/RAGZRS).
storage_shared_access_key_enabled bool true No Allow account-key access to storage. Set false for keyless (managed identity).
storage_public_network_access_enabled bool true No Allow public network access to the storage account.
application_insights_enabled bool true No Create and wire an Application Insights component.
application_insights_name string null No Override the App Insights name (defaults to <name>-ai).
application_insights_retention_days number 90 No App Insights retention (30-730, allowed steps only).
log_analytics_workspace_id string null No Workspace ID for workspace-based App Insights.
maximum_elastic_worker_count number 20 No Max elastic workers (Elastic Premium only).
zone_balancing_enabled bool false No Spread plan instances across availability zones.
public_network_access_enabled bool true No Allow public access to the app’s HTTP endpoint.
virtual_network_subnet_id string null No Subnet for regional VNet integration (null disables).
identity_type string "SystemAssigned" No SystemAssigned, UserAssigned, both, or null.
identity_ids list(string) [] No User-assigned identity IDs (required when type includes UserAssigned).
app_settings map(string) {} No Extra app settings merged over module defaults.
allowed_ip_cidrs list(string) [] No CIDR ranges allowed to reach the app (empty = allow all).
cors_allowed_origins list(string) [] No CORS allowed origins.
cors_support_credentials bool false No Allow credentials in CORS requests (not with *).
tags map(string) {} No Tags for the app and its dependencies.

Outputs

Name Description
id Resource ID of the Function App.
name Name of the Function App.
default_hostname Default hostname (e.g. myfunc.azurewebsites.net) for invoking the app.
identity_principal_id System-assigned identity principal ID, for Key Vault / Storage grants (null if none).
outbound_ip_addresses Comma-separated outbound IPs, for downstream firewall allow-lists.
service_plan_id Resource ID of the service plan the app runs on.
storage_account_name Name of the backing storage account.
application_insights_connection_string App Insights connection string, marked sensitive (null if disabled).
application_insights_instrumentation_key App Insights instrumentation key, marked sensitive (null if disabled).

Enterprise scenario

A logistics company processes order events from Azure Service Bus through a fleet of Functions. The platform team stamps out one Function App per bounded context (orders, shipments, billing) from this module on EP1 Elastic Premium plans, each with storage_shared_access_key_enabled = false so the app authenticates to its backing storage and Key Vault purely through its managed identity — no account keys anywhere in config. VNet integration via virtual_network_subnet_id pins egress to the hub network so the Functions can reach a private SQL Managed Instance, and the auto-created Application Insights (wired to a shared Log Analytics workspace) gives one correlated trace view across every context with zero hand-written instrumentation settings.

Best practices

TerraformAzureFunction 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