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
- You run event-driven or HTTP serverless workloads — webhooks, queue/Service Bus processors, scheduled jobs, blob-triggered pipelines, lightweight APIs — and want them all provisioned identically.
- You deploy the same function code across dev/test/prod and want only a
tfvarschange between them (Consumption in dev, Elastic Premium with VNet integration in prod). - You need keyless security: a managed identity that pulls connection strings from Key Vault and reads the backing storage with
Storage Blob Data Ownerinstead of an embedded account key. - You want observability by default — Application Insights created and connected without hand-wiring
APPLICATIONINSIGHTS_CONNECTION_STRING. - Reach for a Container App or AKS instead when the workload is a long-running container or needs fine-grained traffic splitting; reach for
azurerm_windows_function_appif you are pinned to a Windows-only runtime. This module is for Linux Functions on a managed plan.
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 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/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
- Go keyless for the backing storage. Set
storage_shared_access_key_enabled = falseso the module switches tostorage_uses_managed_identityand grant the app’sidentity_principal_idtheStorage Blob Data Ownerrole — a leaked storage key otherwise exposes the function key store and deployment package. - Pin runtime and host versions explicitly. Always set
functions_extension_version = "~4"and an exactruntime_version; running an out-of-support host (~3and earlier) blocks security patches and can stop the app cold during a platform retirement. - Match the plan SKU to the latency profile. Use
Y1Consumption for spiky, cold-start-tolerant jobs to pay per execution; move toEP1+ Elastic Premium only when you need no cold starts, VNet integration, or longer runtimes — it costs far more, so don’t default prod to it reflexively. - Keep secrets in Key Vault, not app settings. Reference secrets as
@Microsoft.KeyVault(SecretUri=...)inapp_settingsrather than pasting connection strings inline, so rotation happens in one place and nothing sensitive lands in the Terraform state as plaintext. - Lock down ingress in production. Set
public_network_access_enabled = falsewith VNet integration, or useallowed_ip_cidrsto restrict the HTTP endpoint; combine with the module’s enforcedhttps_onlyand disabled FTPS for a tight default surface. - Name deterministically and tag for ownership. Use a
{org}-{env}-{context}-funcconvention for the app and a separate globally-unique alphanumeric name for its storage account, and lean on the module’smanaged_by = terraformtag plus your ownenvironment/ownertags for clean cost attribution.