Quick take — A reusable hashicorp/azurerm ~> 4.0 module for azurerm_logic_app_standard: deploy single-tenant Logic Apps on a Workflow Standard plan with VNet integration, managed identity, and app settings as code. 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 "logic_app" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-logic-app?ref=v1.0.0"
name = "..." # Logic App (Standard) site name; globally unique within …
resource_group_name = "..." # Resource group for the plan, storage, and Logic App.
location = "..." # Azure region (e.g. `centralindia`).
service_plan_name = "..." # Name of the dedicated Workflow Standard App Service Pla…
storage_account_name = "..." # Backing storage account (run history / state / content …
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Logic App (Standard) is the single-tenant evolution of Azure Logic Apps. Unlike the original Consumption SKU — which is metered per-action on shared multi-tenant infrastructure — Standard runs stateful and stateless workflows on the single-tenant Logic Apps runtime, packaged on top of the Azure Functions host and hosted on a dedicated Workflow Standard (WS1/WS2/WS3) App Service Plan. That architectural difference is the whole point: you get predictable per-plan pricing instead of per-execution billing, local development with the workflow runtime, the ability to run multiple workflows inside one app, and — critically for enterprises — regional VNet integration and private endpoints so triggers and actions can reach into private networks.
In Terraform, the entire thing is modelled by azurerm_logic_app_standard. The catch is that it is essentially a Functions-app-shaped resource: it requires an App Service Plan (azurerm_service_plan), a backing storage account (the workflow runtime keeps run history, dehydrated stateful state, and host metadata there), and a long list of app_settings that are not optional — get WEBSITE_CONTENTSHARE, the storage connection strings, or APP_KIND wrong and the app silently fails to start its workflows. Wrapping all of that in a module turns a fiddly, easy-to-misconfigure stack into one vetted call: the plan SKU, storage wiring, runtime version, managed identity, and VNet integration are all encoded once and reused everywhere.
When to use it
Use this module when you want single-tenant, dedicated-capacity Logic Apps rather than Consumption:
- You need VNet integration / private endpoints so a workflow can call an internal API, a private SQL Server, or a Service Bus namespace locked behind private networking — Consumption Logic Apps cannot do this.
- You run many workflows (orchestrations, B2B/EDI flows, scheduled jobs) and want them co-located on one billed plan with predictable cost instead of unbounded per-action charges.
- You want stateful workflows with run history plus stateless workflows for low-latency request/response, both in the same app.
- You need a managed identity on the workflow host to pull secrets from Key Vault or authenticate to downstream Azure resources without connection secrets.
- You deploy through CI/CD and want the workflow definitions (the
workflow.jsonfiles) shipped as a zip/package while the infrastructure is governed by Terraform.
Prefer the Consumption SKU (azurerm_logic_app_workflow) instead when you have a handful of low-volume, internet-reachable integrations and want to pay nothing when idle — this module is deliberately about the dedicated-plan, network-integrated case.
Module structure
terraform-module-azure-logic-app/
├── versions.tf # provider + Terraform version pinning
├── main.tf # service plan, storage account, logic app standard, VNet integration
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id/name, default hostname, identity, kind
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
# Backing storage for the single-tenant workflow runtime:
# run history, dehydrated stateful state, and host metadata all live here.
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
account_kind = "StorageV2"
min_tls_version = "TLS1_2"
https_traffic_only_enabled = true
shared_access_key_enabled = true # required by the Functions/Logic runtime content share
allow_nested_items_to_be_public = false
tags = var.tags
}
# Dedicated Workflow Standard (WS) plan — this is what makes it "Standard".
resource "azurerm_service_plan" "this" {
name = var.service_plan_name
resource_group_name = var.resource_group_name
location = var.location
os_type = "Windows"
sku_name = var.plan_sku_name # WS1 / WS2 / WS3
tags = var.tags
}
resource "azurerm_logic_app_standard" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
app_service_plan_id = azurerm_service_plan.this.id
storage_account_name = azurerm_storage_account.this.name
storage_account_access_key = azurerm_storage_account.this.primary_access_key
storage_account_share_name = var.content_share_name
https_only = true
virtual_network_subnet_id = var.vnet_integration_subnet_id
# The single-tenant runtime version. "~4" tracks the v4 extension bundle line.
version = var.runtime_version
site_config {
# Stateful state store + connection runtime live in the same .NET worker.
dotnet_framework_version = "v6.0"
ftps_state = "Disabled"
http2_enabled = true
min_tls_version = "1.2"
vnet_route_all_enabled = var.vnet_integration_subnet_id != null
dynamic "ip_restriction" {
for_each = var.allowed_ip_cidrs
content {
ip_address = ip_restriction.value
action = "Allow"
priority = 100 + ip_restriction.key
name = "allow-${ip_restriction.key}"
}
}
}
app_settings = merge(
{
# APP_KIND tells the runtime this is a single-tenant Logic App, not a plain Function app.
"APP_KIND" = "workflowApp"
"AzureFunctionsJobHost__extensionBundle__id" = "Microsoft.Azure.Functions.ExtensionBundle.Workflows"
"AzureFunctionsJobHost__extensionBundle__version" = "[1.*, 2.0.0)"
# Content share + content connection are mandatory for the workflow runtime to boot.
"WEBSITE_CONTENTAZUREFILECONNECTIONSTRING" = azurerm_storage_account.this.primary_connection_string
"WEBSITE_CONTENTSHARE" = var.content_share_name
},
var.app_settings
)
dynamic "identity" {
for_each = var.identity_type == null ? [] : [1]
content {
type = var.identity_type
identity_ids = var.identity_type == "UserAssigned" ? var.user_assigned_identity_ids : null
}
}
tags = var.tags
lifecycle {
# WEBSITE_CONTENTSHARE must never be rebuilt on the fly; protect it from drift churn.
ignore_changes = [
app_settings["WEBSITE_CONTENTAZUREFILECONNECTIONSTRING"],
]
}
}
variables.tf
variable "name" {
type = string
description = "Name of the Logic App (Standard) site. Must be globally unique within azurewebsites.net."
validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$", var.name))
error_message = "name must be 3-60 chars, lowercase alphanumeric and hyphens, not starting/ending with a hyphen."
}
}
variable "resource_group_name" {
type = string
description = "Resource group that will hold the plan, storage account, and Logic App."
}
variable "location" {
type = string
description = "Azure region (e.g. centralindia, eastus)."
}
variable "service_plan_name" {
type = string
description = "Name of the dedicated Workflow Standard App Service Plan."
}
variable "plan_sku_name" {
type = string
description = "Workflow Standard plan SKU."
default = "WS1"
validation {
condition = contains(["WS1", "WS2", "WS3"], var.plan_sku_name)
error_message = "plan_sku_name must be a Workflow Standard SKU: WS1, WS2, or WS3."
}
}
variable "storage_account_name" {
type = string
description = "Backing storage account name (run history / state / content share). 3-24 lowercase alphanumeric."
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."
}
}
variable "storage_replication_type" {
type = string
description = "Replication for the backing storage account."
default = "LRS"
validation {
condition = contains(["LRS", "ZRS", "GRS", "GZRS"], var.storage_replication_type)
error_message = "storage_replication_type must be one of LRS, ZRS, GRS, GZRS."
}
}
variable "content_share_name" {
type = string
description = "Azure Files share name used for WEBSITE_CONTENTSHARE. Keep it stable across deploys."
default = "logicapp-content"
}
variable "runtime_version" {
type = string
description = "Single-tenant Logic Apps runtime version (Functions host version line)."
default = "~4"
validation {
condition = contains(["~3", "~4"], var.runtime_version)
error_message = "runtime_version must be ~3 or ~4."
}
}
variable "vnet_integration_subnet_id" {
type = string
description = "Subnet ID for regional VNet integration. The subnet must be delegated to Microsoft.Web/serverFarms. Null disables integration."
default = null
}
variable "identity_type" {
type = string
description = "Managed identity type: SystemAssigned, UserAssigned, SystemAssigned, UserAssigned, or null for none."
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 "user_assigned_identity_ids" {
type = list(string)
description = "User-assigned identity resource IDs (required when identity_type includes UserAssigned)."
default = []
}
variable "allowed_ip_cidrs" {
type = list(string)
description = "CIDR ranges allowed to reach the app's inbound endpoint. Empty list = no IP restriction added."
default = []
}
variable "app_settings" {
type = map(string)
description = "Extra app settings merged on top of the mandatory runtime settings (e.g. Key Vault references, connection params)."
default = {}
}
variable "tags" {
type = map(string)
description = "Tags applied to all created resources."
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the Logic App (Standard)."
value = azurerm_logic_app_standard.this.id
}
output "name" {
description = "Name of the Logic App (Standard)."
value = azurerm_logic_app_standard.this.name
}
output "default_hostname" {
description = "Default hostname (<name>.azurewebsites.net) used for the request/webhook trigger callback URLs."
value = azurerm_logic_app_standard.this.default_hostname
}
output "kind" {
description = "Kind reported by Azure (functionapp,workflowapp for single-tenant Logic Apps)."
value = azurerm_logic_app_standard.this.kind
}
output "outbound_ip_addresses" {
description = "Comma-separated outbound IPs the app uses for actions — useful for downstream firewall allowlists."
value = azurerm_logic_app_standard.this.outbound_ip_addresses
}
output "identity_principal_id" {
description = "Principal ID of the system-assigned identity (null if none), for Key Vault / RBAC grants."
value = try(azurerm_logic_app_standard.this.identity[0].principal_id, null)
}
output "service_plan_id" {
description = "Resource ID of the underlying Workflow Standard App Service Plan."
value = azurerm_service_plan.this.id
}
How to use it
module "logic_app_standard_integration" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-logic-app?ref=v1.0.0"
name = "kv-int-orders-prod"
resource_group_name = azurerm_resource_group.integration.name
location = "centralindia"
service_plan_name = "asp-kv-int-prod"
plan_sku_name = "WS2" # bump from WS1 for higher throughput / more vCPU
storage_account_name = "kvintordersprodst"
storage_replication_type = "ZRS"
# Reach the private order API and private SQL behind the spoke VNet.
vnet_integration_subnet_id = azurerm_subnet.logicapp_integration.id
identity_type = "SystemAssigned"
app_settings = {
# Pull the downstream API key from Key Vault via a reference, not a literal secret.
"OrdersApi__ApiKey" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.orders_api_key.id})"
"ServiceBus__Namespace" = azurerm_servicebus_namespace.orders.name
"WORKFLOWS_TENANT_ID" = data.azurerm_client_config.current.tenant_id
}
tags = {
environment = "prod"
workload = "order-integration"
owner = "platform-team"
}
}
# Downstream reference: grant the Logic App's system identity read access to Key Vault.
resource "azurerm_role_assignment" "logicapp_kv_reader" {
scope = azurerm_key_vault.integration.id
role_definition_name = "Key Vault Secrets User"
principal_id = module.logic_app_standard_integration.identity_principal_id
}
# Downstream reference: allowlist the app's outbound IPs on the SQL server firewall.
output "logicapp_outbound_ips" {
value = module.logic_app_standard_integration.outbound_ip_addresses
}
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/logic_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-logic-app?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
service_plan_name = "..."
storage_account_name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/logic_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 | Logic App (Standard) site name; globally unique within azurewebsites.net. |
| resource_group_name | string | — | Yes | Resource group for the plan, storage, and Logic App. |
| location | string | — | Yes | Azure region (e.g. centralindia). |
| service_plan_name | string | — | Yes | Name of the dedicated Workflow Standard App Service Plan. |
| plan_sku_name | string | "WS1" |
No | Workflow Standard SKU: WS1, WS2, or WS3. |
| storage_account_name | string | — | Yes | Backing storage account (run history / state / content share); 3-24 lowercase alphanumeric. |
| storage_replication_type | string | "LRS" |
No | Storage replication: LRS, ZRS, GRS, GZRS. |
| content_share_name | string | "logicapp-content" |
No | Azure Files share for WEBSITE_CONTENTSHARE; keep stable across deploys. |
| runtime_version | string | "~4" |
No | Single-tenant runtime version: ~3 or ~4. |
| vnet_integration_subnet_id | string | null |
No | Delegated subnet ID for regional VNet integration; null disables it. |
| identity_type | string | "SystemAssigned" |
No | SystemAssigned, UserAssigned, "SystemAssigned, UserAssigned", or null. |
| user_assigned_identity_ids | list(string) | [] |
No | User-assigned identity IDs (required when type includes UserAssigned). |
| allowed_ip_cidrs | list(string) | [] |
No | CIDR ranges allowed inbound; empty = no IP restriction. |
| app_settings | map(string) | {} |
No | Extra app settings merged over the mandatory runtime settings. |
| tags | map(string) | {} |
No | Tags applied to all created resources. |
Outputs
| Name | Description |
|---|---|
| id | Resource ID of the Logic App (Standard). |
| name | Name of the Logic App (Standard). |
| default_hostname | <name>.azurewebsites.net, used for request/webhook trigger callback URLs. |
| kind | Azure-reported kind (functionapp,workflowapp). |
| outbound_ip_addresses | Comma-separated outbound IPs for downstream firewall allowlists. |
| identity_principal_id | Principal ID of the system-assigned identity (null if none). |
| service_plan_id | Resource ID of the underlying Workflow Standard App Service Plan. |
Enterprise scenario
A retail platform team runs order-fulfilment integrations that must call an internal pricing API and a private Azure SQL Managed Instance — neither of which is reachable from the multi-tenant Consumption SKU. They stamp out one Logic App (Standard) per environment via this module on a WS2 plan with regional VNet integration into the spoke network, a system-assigned identity wired to Key Vault for the API key, and the plan’s outbound IPs fed straight into the SQL firewall rule. Workflow definitions ship through the Azure DevOps pipeline as a zip-deploy package, so the same Terraform stack governs dev, staging, and prod identically while developers iterate on the workflow.json files independently.
Best practices
- Treat
WEBSITE_CONTENTSHAREand the storage account as load-bearing, not incidental. The single-tenant runtime stores run history and dehydrated stateful state there; recreating the storage account or changing the content share name mid-life will drop in-flight runs and break the host. The module pins the share name andignore_changeson the content connection string to prevent accidental churn. - Right-size the WS plan, then scale out — not up — first. Start at
WS1and move toWS2/WS3only when CPU/memory pressure is proven; for burst throughput, prefer adding plan instances. Because billing is per-plan, an oversized or duplicated plan is the single biggest avoidable cost on Standard. - Use a managed identity over connection secrets. Set
identity_type = "SystemAssigned", grant itKey Vault Secrets User, and reference secrets with@Microsoft.KeyVault(...)app settings — keep raw connection strings and API keys out of state and out of the workflow definitions. - Lock down both inbound and outbound paths. Enable VNet integration with
vnet_route_all_enabledso action traffic egresses through your network, restrict the inbound endpoint withallowed_ip_cidrs(or front it with a private endpoint), and keephttps_only,min_tls_version = "1.2", andftps_state = "Disabled". - Choose
ZRS/GZRSstorage replication for production workflows. Run state durability is only as good as the backing account’s replication; LRS is fine for dev, but zone- or geo-redundant storage protects stateful run history for business-critical integrations. - Standardise naming and tagging across the trio. The plan, storage account, and Logic App should share a consistent prefix and the same tag set (environment, workload, owner) so cost allocation and blast-radius reasoning stay clean — the module applies one
tagsmap to all three.