IaC Azure

Terraform Module: Azure Logic App (Standard) — single-tenant workflow runtime on a dedicated plan, codified

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:

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 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/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

TerraformAzureLogic App (Standard)ModuleIaC
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