Skip to Content
DocumentsAzure Logic App Standards

Azure Logic App Standards

Production standards for building Azure Logic Apps that are consistent, secure, maintainable, and traceable - from hosting model selection through Sentinel incident triggers, HTTP endpoints, KQL scheduling, networking, retries, Terraform, and observability.

Microsoft reference: Azure Logic Apps overview  · Standard vs Consumption  · Sentinel playbooks 


Hosting Model: Consumption vs Standard

The first decision before writing any workflow is which hosting model to use. Getting this wrong is costly - migrating between models requires rebuilding the workflow from scratch.

Decision tree

Work through these questions in order. Stop at the first that matches your requirement.

QuestionYes →No ↓
Does the workflow need VNet integration or private endpoints?Standard
Does the workflow need a static, dedicated outbound IP?Standard
Will this resource host more than one workflow?Standard
Does this workflow need stateless execution (no run history storage)?Standard
Is expected throughput > 100,000 action executions per 5 minutes?Standard
Does this workflow need custom built-in connectors or local function execution?Standard
Is cost optimisation via pay-per-execution more important than fixed cost?Consumption
Is this a simple event-driven integration with low, bursty volume?ConsumptionStandard

Sentinel playbooks: Both Consumption and Standard Logic Apps can serve as Sentinel playbooks. Consumption is simpler to onboard (Sentinel’s built-in playbook gallery uses it by default). Standard is required if the playbook must reach private resources. When in doubt, Standard is the more production-ready choice - see Sentinel automation .

Consumption (Multitenant)

PropertyValue
HostingShared, multitenant
Workflows per resourceOne
BillingPay per action execution
NetworkingShared IPs, no VNet integration
Outbound IPsShared, unpredictable (see IP list )
Concurrency limit25 concurrent runs (default, max 100)
Action timeout120 seconds per action
When to useSentinel playbooks with no private network requirements; low-volume event-driven automation; proof-of-concept

Standard (Single-tenant)

PropertyValue
HostingSingle-tenant (App Service Plan / Workflow Service Plan)
Workflows per resourceMultiple stateful and stateless
BillingFixed plan cost + storage transactions
NetworkingVNet integration, private endpoints, dedicated subnet
Outbound IPsDedicated and predictable (the App Service Plan IPs)
Concurrency limit100 concurrent runs (default, configurable)
Action timeoutConfigurable (default 1 hour for stateful, 5 min for stateless)
When to useProduction automation touching private resources; workflows requiring consistent egress IPs; high-throughput pipelines; multi-workflow resources

Standard SKUs

SKUvCPUsRAMUse case
WS113.5 GBDev, low-volume production
WS227 GBMid-volume, concurrent workflows
WS3414 GBHigh-volume, CPU-intensive transformation

Start at WS1 and scale based on observed CPU and memory under load.


Organizational Templates (public preview)

Organizational templates allow you to create reusable workflow templates that your team can instantiate with pre-configured triggers, actions, naming, authentication, and infrastructure. Rather than each developer starting from a blank canvas, they create a new Logic App from your template, significantly reducing setup time and ensuring consistency across the organisation.

Status: Public preview. See Microsoft announcement  and official documentation .

When to use templates

Templates are valuable when you have repeatable integration patterns used by multiple teams or projects. Common use cases:

  • Sentinel playbooks: Template includes Sentinel incident trigger, role assignments, error handling, and Teams/email notification actions. Developers create a playbook from the template and customise only the core logic.
  • Event-driven integrations: Template includes Azure Event Grid or Service Bus trigger, retry policy, managed identity setup, and standard error scope. Developers add their own action sequence.
  • Scheduled KQL reports: Template includes Recurrence trigger, Log Analytics query action, result processing loop, and email distribution. Developers modify the query and recipients.
  • API synchronisation: Template includes polling trigger, timestamp tracking, batch size limits, and run-after error handling. Developers add connector-specific actions.

When to skip templates

  • One-off integrations: A single Logic App that will never be reused. The overhead of creating and maintaining a template is not justified.
  • Workflows with fundamentally different patterns: If each instance needs a different trigger type, hosting model, or error strategy, a template adds constraint rather than value.
  • High-velocity prototyping: During exploration, developers may benefit from flexibility over enforced consistency.

Creating a template

Before building from scratch, check the Azure/LogicAppsTemplates  repository for official Microsoft templates. Many common patterns (Sentinel playbooks, Event Grid handlers, Service Bus processors) already exist as templates you can adapt or use as reference implementations.

  1. Build a reference workflow in the portal or designer that exemplifies your pattern. Include:

    • The trigger type and basic configuration
    • Standard actions (e.g. Managed Identity connections, error scope structure)
    • Placeholder names and parameters for customisation
    • Documentation via action comments describing what each step does and what the developer should modify
  2. Export as a template via the Azure portal (Logic App > Templates > Export). This captures the workflow definition in a reusable JSON schema.

  3. Configure template parameters to mark which fields the developer must or may customise:

    • Required: trigger topic/queue/schedule, email recipients, Teams channel
    • Optional: retry counts, timeout values, condition thresholds
  4. Document the template with:

    • Purpose and supported use cases
    • Required permissions (which role assignments must be created)
    • Required supporting resources (e.g. Log Analytics workspace, Event Grid topic)
    • Example developer workflow (which fields to change, what not to touch)
    • Troubleshooting guide for common configuration errors
  5. Store the template in your organization’s Logic Apps template gallery (Azure Portal > Logic Apps > Create > Organizational templates > Add to gallery). This makes it discoverable to all teams.

Template + Terraform

Even when using a template to bootstrap the workflow, infrastructure should still be managed in Terraform:

  • The template creates the Logic App resource and workflow definition
  • Terraform defines the supporting infrastructure: managed identities, role assignments, API connections, storage accounts, diagnostic settings, and networking
  • The template’s generated workflow JSON (stored in the Logic App) can be version-controlled in a repository alongside the Terraform code

This hybrid approach gives developers the speed of a template while maintaining Infrastructure-as-Code discipline.


Naming & Tagging

Resource name

Follow the Azure Naming Convention. Both Consumption (Microsoft.Logic/workflows) and Standard (Microsoft.Web/sites) Logic Apps use the logic- prefix.

PLAINTEXT
# Consumption (Microsoft.Logic/workflows) and Standard (Microsoft.Web/sites)
logic-{infix}-{outfix}-{suffix}-{numbering}
logic-ldo-uks-prd-01
logic-ldo-uks-dev-01
logic-ldo-uks-prd-02

Supporting resources follow their own conventions:

ResourcePrefixExample
Logic App (Consumption + Standard)logic-logic-ldo-uks-prd-01
App Service Planplan-plan-ldo-uks-prd-01
Storage Accountsa (no hyphen)saldouksprd01
User-assigned Identityid-id-sentinel-playbooks-ldo-uks-prd-01
API Connection (Microsoft.Web/connections)conn-conn-sentinel-ldo-uks-prd-01
Private Endpointpep-pep-logic-ldo-uks-prd-01
Application Insightsappi-appi-ldo-uks-prd-01
Log Analytics Workspacelog-log-ldo-uks-prd-01
Key Vaultkv-kv-ldo-uks-prd-01

The resource name is short and machine-readable. It identifies what it is and where - not what it does. The name never changes after creation without destroying the resource.

Hidden Title tag

Because the resource name is opaque, every Logic App must carry a hidden-title tag containing a human-readable description of what the workflow does. This tag renders in the Azure Portal resource list as a subtitle beneath the resource name.

HCL
# Terraform
tags = {
  hidden-title = "Sentinel Incident - Enrich and notify Teams on High severity"
  environment  = "prd"
  managed-by   = "terraform"
  module       = "terraform-azurerm-logic-app"
}

The hidden-title convention is an Azure platform feature - the portal reads this tag and surfaces it in the resource overview blade. No other tag key achieves this.

Format for hidden-title: {Trigger type} - {What it does in plain language}

Example triggerExample hidden-title
Sentinel IncidentSentinel Incident - Auto-close TI false positives
HTTPHTTP - Receive webhook from GitHub and create JIRA ticket
RecurrenceScheduled - Daily stale device report to Teams
PollingPolling - ServiceNow new P1 tickets to PagerDuty

Required tags

Every Logic App resource must carry these tags at minimum:

Tag keyExample valuePurpose
hidden-titleSentinel Incident - Enrich on High severityPortal display name
environmentprd, dev, tstEnvironment identification
managed-byterraformDrift detection
ownerplatform-teamIncident escalation
cost-centresec-opsFinOps allocation

Trigger Types

Sentinel Incident trigger

The recommended trigger for all security automation playbooks. Uses the Microsoft Sentinel managed connector.

Trigger: When a Microsoft Sentinel incident is created or updated

This trigger fires on both creation and update events. Always add a condition immediately after the trigger to filter the event type:

PLAINTEXT
Trigger
└── Condition: triggerBody()?['object']?['properties']?['status'] is not equal to 'Closed'
    ├── True: continue with automation
    └── False: Terminate (Cancelled)

Additional filters to apply early (reduces wasted executions):

PLAINTEXT
# Filter to creation events only (avoid re-triggering on enrichment updates):
triggerOutputs()?['body']?['object']?['properties']?['additionalData']?['alertsCount'] > 0

# Filter by severity:
triggerBody()?['object']?['properties']?['severity'] equals 'High'

# Filter by specific analytic rule:
contains(triggerBody()?['object']?['properties']?['title'], 'Brute Force')

Required permissions for the Logic App identity:

RoleScopeWhy
Microsoft Sentinel ResponderSentinel workspace resource groupRead incidents, add comments, update status
Microsoft Sentinel ContributorSentinel workspace resource groupNeeded if playbook writes back to incidents
Logic App ContributorLogic App resourceAllows Sentinel automation rules to trigger it

Rule: Always use Managed Identity for the Sentinel connector, not a user account connection. User account connections break when the user leaves or their password changes.

Sentinel Automation Rule: Link the playbook via an Automation Rule - not the legacy Alert Rule playbook field. Automation Rules evaluate after incident creation/update and support conditions, ordering, and expiry.

PLAINTEXT
Sentinel → Automation rules → + Create
  Trigger: When incident is created
  Conditions: Severity = High
  Actions: Run playbook → select your Logic App

Cross-subscription playbooks: If the Logic App is in a different subscription from the Sentinel workspace, the automation rule must be configured with an explicit resource ID and the Logic App must grant Logic App Contributor to the Sentinel workspace’s managed identity.

HTTP Request trigger

Use for webhook-based integrations - GitHub, JIRA, ServiceNow, custom applications calling the Logic App endpoint.

JSON
{
  "type": "Request",
  "kind": "Http",
  "inputs": {
    "schema": {
      "type": "object",
      "properties": {
        "alertId": { "type": "string" },
        "severity": { "type": "string", "enum": ["High", "Medium", "Low"] },
        "description": { "type": "string" }
      },
      "required": ["alertId", "severity"]
    }
  }
}

Always define a JSON schema on the Request trigger. Without a schema, expressions referencing request body fields are untyped and fail silently when the field is missing.

HTTP trigger endpoint URL: The URL contains an sp, sv, and sig SAS token. This token does not expire by default but should be treated as a secret.

Security controlImplementation
Restrict caller IPsLogic App Settings → Access control → Allowed inbound IP addresses
Require Azure AD authEnable OAuth on the Request trigger (Standard only)
Rotate the SAS tokenRegenerate the trigger URL on a schedule (breaks existing callers - coordinate first)
Use API Management as a front doorRoute all external webhooks through APIM, which handles auth, rate limiting, and IP restriction

Always return a meaningful HTTP response from the HTTP trigger. If the workflow is long-running, respond immediately with 202 Accepted and process asynchronously. Never leave callers waiting for the full workflow duration.

PLAINTEXT
Request trigger
└── Response action (immediately): HTTP 202 Accepted
└── [rest of workflow runs async]

Recurrence trigger (time-based)

Use for scheduled automation: daily reports, periodic KQL scans, nightly cleanup jobs.

JSON
{
  "type": "Recurrence",
  "recurrence": {
    "frequency": "Day",
    "interval": 1,
    "schedule": {
      "hours": ["06"],
      "minutes": [0]
    },
    "timeZone": "GMT Standard Time"
  }
}

Always set timeZone explicitly. Without it, the recurrence runs in UTC. For UK deployments, use "GMT Standard Time" - this automatically respects BST/GMT transitions.

Recurrence trigger does not support waiting runs. If a previous execution is still running when the next fire time arrives, the new execution is skipped. For long-running jobs, set the recurrence interval to be significantly longer than the expected execution time, or switch to a polling pattern with overlap protection.

KQL query pattern (scheduled scan)

Logic Apps cannot run KQL directly - you call an external service to execute the query and process the results. The two patterns:

Pattern 1: Azure Monitor Logs connector (Consumption + Standard)

Use the Azure Monitor Logs managed connector action: Run query and list results.

PLAINTEXT
Recurrence trigger (every 1 hour)
└── Azure Monitor Logs: Run query and list results
    ├── Subscription: [your subscription]
    ├── Resource Group: [Log Analytics RG]
    ├── Resource Type: Log Analytics Workspace
    ├── Resource Name: [workspace name]
    └── Query:
        SecurityEvent
        | where TimeGenerated > ago(1h)
        | where EventID == 4625
        | summarize FailCount = count() by TargetAccount, IpAddress
        | where FailCount > 20
└── Condition: length(body('Run_query_and_list_results')?['value']) > 0
    ├── True: For each result → [send Teams notification / create incident]
    └── False: Terminate (Succeeded)

Always guard the “For each” loop with a result count check. Without this check, a zero-result query still iterates, and any action inside the loop throws an error on empty arrays in some connector versions.

Pattern 2: Sentinel Analytics Rule → Incident → Playbook

The cleaner production pattern. Let Sentinel’s analytics engine run the KQL on its own schedule, create an incident when the threshold is met, and trigger the playbook via an automation rule. This:

  • Offloads KQL execution to Sentinel (designed for it)
  • Gives you incident history, comment trails, and severity tracking
  • Decouples the query schedule from the automation schedule
  • Avoids reimplementing Sentinel’s own deduplication and suppression

Prefer this pattern for anything security-related. Use Pattern 1 only for operational data not in Sentinel (e.g. cost anomalies, performance metrics, non-security Log Analytics workspaces).

Polling trigger

Used for connectors that don’t support push (ServiceNow, older REST APIs, SFTP). The Logic App polls the source on a fixed interval.

PLAINTEXT
Recurrence trigger (every 5 minutes)
└── [Connector]: Get new items since last run
    ├── Use a timestamp variable initialised on first run
    └── Update the timestamp at the end of each successful run
└── Condition: has items
    ├── True: process each item
    └── False: Terminate (Succeeded)

Polling frequency: Match the polling interval to the criticality of the data. Security-critical data: 5 minutes max. Operational reports: 15-60 minutes. Never poll faster than the source API’s rate limit allows.


API Connections & Authentication

Managed Identity (required for production)

All connections to Microsoft services (Sentinel, Azure Monitor, Teams, Key Vault, Storage) must use Managed Identity. Never use a user account or a stored connection string in a production playbook.

System-assigned (standard)

System-assigned managed identity is the default. The identity lifecycle is tied to the Logic App - it is created when the Logic App is created and destroyed when the Logic App is destroyed. Use this for all Logic Apps that are individually scoped (one Logic App, its own permissions, its own rotation boundary).

HCL
resource "azurerm_logic_app_standard" "this" {
  name                = "logic-ldo-uks-prd-01"
  resource_group_name = var.rg_name
  location            = var.location
  # ...
 
  identity {
    type = "SystemAssigned"
  }
}
 
# Role assignment targets the Logic App's own identity
resource "azurerm_role_assignment" "sentinel_responder" {
  scope                = var.sentinel_workspace_resource_id
  role_definition_name = "Microsoft Sentinel Responder"
  principal_id         = azurerm_logic_app_standard.this.identity[0].principal_id
 
  depends_on = [azurerm_logic_app_standard.this]
}

User-assigned (shared permission model)

Use a User-assigned managed identity when multiple Logic Apps need the same set of permissions - for example, a suite of Sentinel playbooks that all need Microsoft Sentinel Responder on the same workspace. The identity is created and managed independently; it can be attached to many resources simultaneously, and role assignments are made once on the identity rather than once per Logic App.

HCL
# Shared identity - created once, attached to all playbooks in the suite
resource "azurerm_user_assigned_identity" "sentinel_playbooks" {
  name                = "id-sentinel-playbooks-ldo-uks-prd-01"
  resource_group_name = var.rg_name
  location            = var.location
  tags                = var.tags
}
 
# Role assignments made once on the shared identity
resource "azurerm_role_assignment" "sentinel_responder" {
  scope                = var.sentinel_workspace_resource_id
  role_definition_name = "Microsoft Sentinel Responder"
  principal_id         = azurerm_user_assigned_identity.sentinel_playbooks.principal_id
}
 
resource "azurerm_role_assignment" "law_reader" {
  scope                = azurerm_log_analytics_workspace.this.id
  role_definition_name = "Log Analytics Reader"
  principal_id         = azurerm_user_assigned_identity.sentinel_playbooks.principal_id
}
 
# All playbooks in the suite use the shared identity.
# Use "this" as the resource label - for_each provides the per-instance key.
locals {
  sentinel_playbook_names = toset([
    "logic-ldo-uks-prd-01",
    "logic-ldo-uks-prd-02",
  ])
}
 
resource "azurerm_logic_app_standard" "this" {
  for_each = local.sentinel_playbook_names
 
  name                = each.key
  resource_group_name = var.rg_name
  location            = var.location
  # ...
 
  identity {
    type         = "UserAssigned"
    identity_ids = [azurerm_user_assigned_identity.sentinel_playbooks.id]
  }
}
 
# Access individual instances via the for_each key:
# azurerm_logic_app_standard.this["logic-ldo-uks-prd-01"]

When to use each:

System-assignedUser-assigned
LifecycleTied to the Logic AppIndependent - survives Logic App deletion
Role assignmentsPer Logic AppOnce per identity, shared across all attached resources
Use caseSingle isolated Logic AppMultiple Logic Apps with identical permission needs
Key rotation boundaryPer Logic AppShared - revoking the identity affects all attached resources
TerraformingSimpler - principal_id is an output of the resourceRequires separate identity resource and explicit attachment

Do not combine System and User-assigned on the same Logic App unless you have a specific reason (e.g. one set of permissions must survive independently). Pick one model per resource. The default is System-assigned.

For Consumption Logic Apps using the Sentinel managed connector with Managed Identity:

  1. Enable System Assigned identity on the Logic App
  2. Grant Microsoft Sentinel Responder to the Logic App’s identity on the workspace RG
  3. In the Logic App Designer, when configuring the Sentinel connector, select Connect with managed identity

Service Principal connection

Use when Managed Identity is unavailable or the connection must be cross-tenant. Store the client secret in Azure Key Vault and reference it via Key Vault references in App Settings.

HCL
app_settings = {
  "SENTINEL_CLIENT_ID"     = var.sentinel_sp_client_id
  "SENTINEL_CLIENT_SECRET" = "@Microsoft.KeyVault(VaultName=${var.kv_name};SecretName=sentinel-sp-secret)"
  "SENTINEL_TENANT_ID"     = var.tenant_id
}

Never use

  • User account connections - break on password changes, MFA prompts, or when the account is disabled. Cause playbook failures with no clear error message.
  • Hardcoded credentials in workflow JSON - visible to anyone with Logic App Reader; cannot be rotated without a code change.
  • Shared access keys stored as plain App Settings - use Key Vault references instead.

API connections in Terraform (azapi)

Managed connector actions (Microsoft Sentinel, Microsoft Teams, Outlook 365) require a Microsoft.Web/connections ARM resource, plus an access policy that authorises the Logic App’s managed identity to use the connection. The azurerm provider has no native resource for this - use the azapi provider instead of azurerm_resource_group_template_deployment, which is harder to read and does not surface connection state in the Terraform plan.

HCL
# terraform.tf - add azapi alongside azurerm
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 4.0.0, < 5.0.0"
    }
    azapi = {
      source  = "azure/azapi"
      version = ">= 2.0.0, < 3.0.0"
    }
  }
}
HCL
# Create the managed API connection
# parameterValueType = "Alternative" enables managed identity auth on the connection
resource "azapi_resource" "sentinel_connection" {
  type      = "Microsoft.Web/connections@2016-06-01"
  name      = "conn-sentinel-ldo-uks-prd-01"
  location  = var.location
  parent_id = "/subscriptions/${var.subscription_id}/resourceGroups/${var.rg_name}"
 
  body = {
    properties = {
      displayName        = "Microsoft Sentinel - Managed Identity"
      parameterValueType = "Alternative"
      api = {
        id = "/subscriptions/${var.subscription_id}/providers/Microsoft.Web/locations/${var.location}/managedApis/azuresentinel"
      }
    }
  }
 
  response_export_values = ["*"]
}
 
# Create the Microsoft Teams connection
resource "azapi_resource" "teams_connection" {
  type      = "Microsoft.Web/connections@2016-06-01"
  name      = "conn-teams-ldo-uks-prd-01"
  location  = var.location
  parent_id = "/subscriptions/${var.subscription_id}/resourceGroups/${var.rg_name}"
 
  body = {
    properties = {
      displayName        = "Microsoft Teams - Managed Identity"
      parameterValueType = "Alternative"
      api = {
        id = "/subscriptions/${var.subscription_id}/providers/Microsoft.Web/locations/${var.location}/managedApis/teams"
      }
    }
  }
 
  response_export_values = ["*"]
}
 
# Grant the Logic App's managed identity access to the Sentinel connection
# This access policy is what allows the Logic App to use the connection at runtime
resource "azapi_resource" "sentinel_connection_access_policy" {
  type      = "Microsoft.Web/connections/accessPolicies@2016-06-01"
  name      = azurerm_logic_app_standard.this.name
  parent_id = azapi_resource.sentinel_connection.id
  location  = var.location
 
  body = {
    properties = {
      principal = {
        type = "ActiveDirectory"
        identity = {
          objectId = azurerm_logic_app_standard.this.identity[0].principal_id
          tenantId = var.tenant_id
        }
      }
    }
  }
 
  depends_on = [
    azapi_resource.sentinel_connection,
    azurerm_logic_app_standard.this,
    azurerm_role_assignment.sentinel_responder,   # role must exist before the connection is used
  ]
}
 
resource "azapi_resource" "teams_connection_access_policy" {
  type      = "Microsoft.Web/connections/accessPolicies@2016-06-01"
  name      = azurerm_logic_app_standard.this.name
  parent_id = azapi_resource.teams_connection.id
  location  = var.location
 
  body = {
    properties = {
      principal = {
        type = "ActiveDirectory"
        identity = {
          objectId = azurerm_logic_app_standard.this.identity[0].principal_id
          tenantId = var.tenant_id
        }
      }
    }
  }
 
  depends_on = [
    azapi_resource.teams_connection,
    azurerm_logic_app_standard.this,
  ]
}

Referencing connections in the workflow JSON:

The workflow definition JSON (stored as workflow.json for Standard, or embedded in the ARM template for Consumption) must reference the connection resource by ID:

JSON
{
  "parameters": {
    "$connections": {
      "value": {
        "azuresentinel": {
          "connectionId": "/subscriptions/.../resourceGroups/.../providers/Microsoft.Web/connections/conn-sentinel-ldo-uks-prd-01",
          "connectionName": "conn-sentinel-ldo-uks-prd-01",
          "connectionProperties": {
            "authentication": {
              "type": "ManagedServiceIdentity"
            }
          },
          "id": "/subscriptions/.../providers/Microsoft.Web/locations/uksouth/managedApis/azuresentinel"
        }
      }
    }
  }
}

Pass the connection ID from Terraform using azapi_resource.sentinel_connection.id rather than hardcoding it.

Rule: The access policy must be created after both the connection resource and the Logic App identity exist, and after the role assignment granting the identity access to Sentinel has been applied. Chain these with explicit depends_on - Terraform cannot infer these relationships from attribute references alone because the access policy body references an objectId string rather than a Terraform resource attribute.


Networking

Consumption: shared infrastructure

Consumption Logic Apps run on shared Azure infrastructure with shared, rotating outbound IP addresses. You cannot pin a Consumption Logic App to a static IP.

  • Outbound IPs: Published by Microsoft per region but shared across all Consumption Logic Apps in that region. The full list  is long - allowlisting it effectively allows all Azure Logic Apps in that region, not just yours.
  • VNet integration: Not available.
  • Private endpoints: Not available.
  • Inbound restriction: Available - configure allowed IP ranges in Logic App Settings → Access control.

Consequence: Never rely on IP allowlisting to authenticate a Consumption Logic App to an external service. Use identity-based auth (OAuth, Managed Identity) instead. If IP allowlisting is a hard requirement, use Standard.

Standard: dedicated networking

Standard Logic Apps run on an App Service Plan, which gives you a predictable, dedicated set of outbound IPs.

The topology below shows a Standard Logic App with outbound VNet integration into a delegated subnet and inbound access restricted to a Private Endpoint:

Standard Logic App reached over a Private Endpoint. A subscription (sub-ldo-prd) contains a resource group (rg-ldo-uks-prd-01) with a virtual network. The Logic App integrates outbound into a delegated /26 subnet (snet-integration, delegated to Microsoft.Web/serverFarms) and is reached inbound through a Private Endpoint in a /27 subnet (snet-private-endpoints) targeting the "sites" subresource.

VNet integration (outbound traffic):

HCL
resource "azurerm_logic_app_standard" "this" {
  for_each = local.logic_app_map
  # ...
  virtual_network_subnet_id = var.integration_subnet_id   # /28 minimum, /26 recommended
}

The integration subnet must be delegated to Microsoft.Web/serverFarms and must not host any other resources.

Private endpoint (inbound traffic):

HCL
resource "azurerm_private_endpoint" "this" {
  for_each = local.logic_app_map
 
  # Convention: pep-{resource_name} - the protected resource name already contains
  # the environment and region, so no suffix is added.
  name                = "pep-${each.key}"
  resource_group_name = var.rg_name
  location            = var.location
  subnet_id           = var.private_endpoint_subnet_id
 
  private_service_connection {
    name                           = "psc-${each.key}"
    private_connection_resource_id = azurerm_logic_app_standard.this[each.key].id
    subresource_names              = ["sites"]
    is_manual_connection           = false
  }
}

When a private endpoint is configured:

  • The public HTTP trigger URL still exists but can be disabled via public_network_access = "Disabled"
  • Sentinel Automation Rules can still trigger the playbook directly via the ARM plane, regardless of network access settings
  • HTTP webhook callers must be within the VNet or use a peered/connected network

Outbound IP stability:

The outbound IPs of a Standard Logic App are the outbound IPs of its underlying App Service Plan. These are stable and listed in the Azure Portal under the App Service Plan → Properties → Outbound IP addresses. Use these for allowlisting in third-party services (ServiceNow, Splunk, external APIs).

Bash
# Get outbound IPs via CLI
az webapp show \
  --name logic-ldo-uks-prd-01 \
  --resource-group rg-ldo-uks-prd-01 \
  --query outboundIpAddresses -o tsv

Network Security Group on the integration subnet:

PLAINTEXT
Inbound:
  Allow: Azure Logic Apps service tag → subnet (for platform health checks)

Outbound:
  Allow: subnet → Log Analytics workspace (AzureMonitor service tag) - for diagnostics
  Allow: subnet → Key Vault private endpoint (port 443)
  Allow: subnet → Storage account (Azure Storage service tag) - Standard stateful storage
  Allow: subnet → external services (specific IPs/FQDNs per integration)
  Deny: all other outbound

Inbound security for HTTP triggers

For Standard Logic Apps with HTTP triggers, layer multiple controls:

  1. Private endpoint: Prevent public internet access entirely
  2. IP restriction in App Settings: Allows only specific CIDR ranges, even within the VNet
  3. Azure API Management: Route all external webhooks through APIM, which handles mTLS, OAuth, and rate limiting
  4. SAS signature: The trigger URL includes a signature - keep it secret and rotate it on a schedule

Retries & Error Handling

Default retry policy

All managed connector actions have a default retry policy of 4 retries with exponential backoff starting at 7.5 seconds. For most integrations this is sufficient. Verify and override where needed.

JSON
{
  "type": "ApiConnection",
  "inputs": { "...": "..." },
  "runAfter": {},
  "runtimeConfiguration": {
    "staticResult": { "name": "...", "staticResultOptions": "Disabled" }
  },
  "operationOptions": "DisableAsyncPattern",
  "retryPolicy": {
    "type": "exponential",
    "count": 4,
    "interval": "PT7.5S",
    "minimumInterval": "PT5S",
    "maximumInterval": "PT1H"
  }
}

Retry policy types:

TypeUse case
exponentialDefault for most actions - backs off to avoid overwhelming the target
fixedUse when the target has a known recovery time (e.g. “retry every 30s”)
noneFire-and-forget actions where retrying would cause duplicate side effects

Rule: Set type: none on actions that are not idempotent - Teams notifications, email sends, JIRA ticket creation. Retrying these creates duplicate messages. Set exponential on all read operations and safe writes.

Run After (structured error handling)

Use Run After configuration to build error handling branches that fire on failure, timeout, or skip conditions.

PLAINTEXT
Action: Call External API
├── Run After: preceding step succeeded
├── On Success: continue
└── On Failure / Timeout / Skipped:
    └── Scope: Error Handler
        ├── Send Teams alert: "Logic App {{workflow().name}} failed at {{actions('Call_External_API').startTime}}"
        ├── Add Sentinel incident comment: "Automation failed - manual review required"
        └── Terminate: Failed (with descriptive error message)

Scope blocks for structured try/catch

Wrap the main workflow body in a Scope block. Add a second Scope block configured to run after the first scope fails:

PLAINTEXT
Scope: Main Workflow
  ├── [all your workflow actions]
  └── runs on: succeeded

Scope: Error Handler
  ├── runs on: Main Workflow failed OR timed out OR skipped
  ├── Send failure notification
  ├── Optionally: Update Sentinel incident status to "In Progress" with comment
  └── Terminate action: Failed

Always end a failure handler with a Terminate action set to Failed. Without this, the workflow run reports Succeeded even though the main logic failed - this silently swallows errors.

Sentinel-specific error handling

When the playbook is triggered by Sentinel, always write back to the incident regardless of success or failure:

PLAINTEXT
On success:
  Sentinel: Add comment to incident
    Comment: "Playbook completed successfully. [Summary of actions taken]."
  Sentinel: Update incident status (if appropriate: Closed/Active)

On failure:
  Sentinel: Add comment to incident
    Comment: "⚠️ Playbook failed at step [action name]. Error: [error message]. Manual review required."
  Do NOT close or change the incident status - leave it for human review

Timeout considerations

HostingAction timeoutWorkflow timeout
Consumption120 seconds per action90 days total
Standard (stateful)Configurable (default 1 hour)No limit
Standard (stateless)Configurable (default 5 minutes)5 minutes

Standard stateless workflows cannot call asynchronous actions or polling connectors. If your workflow uses HTTP triggers with async responses, Azure Monitor polling, or any action that takes >5 minutes, use stateful.

For long-running operations (batch processing, large KQL result sets), use the async HTTP pattern :

PLAINTEXT
1. HTTP request → Logic App HTTP trigger
2. Logic App responds immediately: 202 Accepted + Location header (status endpoint URL)
3. Caller polls the status URL
4. Logic App updates a Storage Table with run status
5. Status endpoint returns 200 OK when complete

Terraform

Before choosing whether to use a module or write raw resources, decide whether the Logic App is cookie-cutter or custom. This distinction determines the right Terraform approach.

A cookie-cutter Logic App has all of the following:

  • Standard hosting: Linux OS, WS1-WS3 SKU, App Service Plan
  • Standard identity: System-assigned (or a single shared User-assigned) managed identity
  • Standard networking: optional VNet integration subnet, optional private endpoint - no ASE, no special network policies
  • Standard site config: always_on, TLS 1.2, FTPS disabled, HTTP2
  • Standard App Settings: App Insights key, Key Vault references for secrets, no custom environment injection at deploy time
  • No resource-level lifecycle overrides beyond ignore_changes on app_settings["WEBSITE_RUN_FROM_PACKAGE"]
  • No cross-resource dependencies that require orchestrating creates/destroys outside of Terraform’s normal graph

If all of the above apply, use the module. The module’s list(object) input means you can declare several cookie-cutter Logic Apps in a single call without duplicating configuration.

A custom Logic App has any of the following:

  • App Service Environment v3 (ASEv3) hosting
  • Multiple storage accounts per Logic App (e.g. separate accounts for different workflow groups)
  • Complex lifecycle blocks - create_before_destroy, prevent_destroy, per-attribute ignore_changes beyond the standard set
  • Per-resource provider aliases or multi-subscription deployments
  • Custom scaling rules that attach to the App Service Plan in a way the module does not expose
  • Dependency relationships on resources the module does not manage (e.g. the Logic App must only be created after a specific database migration completes)
  • Any situation where the module abstraction forces you to work around it rather than with it

If any of the above apply, do not use the module - write raw azurerm_logic_app_standard, azurerm_service_plan, and supporting resources directly. Fighting a module to accommodate a non-standard requirement produces harder-to-read code than writing the resources explicitly.

Rule: Modules exist to reduce repetition for standard cases. They are not a substitute for understanding the underlying resources. If you cannot immediately explain what a module call expands to, you should not be using it in a production deployment.

HCL
# terraform.tf
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 4.0.0, < 5.0.0"
    }
    azapi = {
      source  = "azure/azapi"
      version = ">= 2.0.0, < 3.0.0"
    }
  }
}
 
# main.tf
module "logic_apps" {
  source = "github.com/libre-devops/terraform-azurerm-logic-app"
 
  location = var.location
  rg_name  = azurerm_resource_group.this.name
 
  logic_apps = [
    {
      name                       = "logic-ldo-uks-prd-01"
      app_service_plan_name      = "plan-ldo-uks-prd-01"
      os_type                    = "Linux"
      sku_name                   = "WS1"
      storage_account_name       = azurerm_storage_account.logic_app.name
      storage_account_access_key = azurerm_storage_account.logic_app.primary_access_key
      https_only                 = true
      enabled                    = true
      identity_type              = "SystemAssigned"
      public_network_access      = "Disabled"
      virtual_network_subnet_id  = azurerm_subnet.integration.id
 
      app_settings = {
        "APPINSIGHTS_INSTRUMENTATIONKEY"        = azurerm_application_insights.this.instrumentation_key
        "APPLICATIONINSIGHTS_CONNECTION_STRING" = azurerm_application_insights.this.connection_string
        "WEBSITE_RUN_FROM_PACKAGE"              = "1"
      }
 
      site_config = {
        always_on       = true
        min_tls_version = "1.2"
        ftps_state      = "Disabled"
        http2_enabled   = true
      }
 
      tags = {
        hidden-title = "Sentinel Incident - Enrich and notify Teams on High severity"
        environment  = var.environment
        managed-by   = "terraform"
        owner        = "platform-team"
        cost-centre  = "sec-ops"
      }
    }
  ]
 
  tags = var.tags
}

Raw resource (custom)

When the Logic App is non-standard, write the resources explicitly. This is more verbose but completely transparent - no module abstraction to look through.

HCL
# App Service Plan - written directly when the module's plan configuration is insufficient
resource "azurerm_service_plan" "this" {
  name                = "plan-ldo-uks-prd-01"
  resource_group_name = azurerm_resource_group.this.name
  location            = var.location
  os_type             = "Linux"
  sku_name            = "WS2"
 
  # Example custom requirement: zone balancing enabled - not exposed by the module
  zone_balancing_enabled = true
 
  tags = var.tags
}
 
# Logic App - written directly
resource "azurerm_logic_app_standard" "this" {
  name                       = "logic-ldo-uks-prd-01"
  resource_group_name        = azurerm_resource_group.this.name
  location                   = var.location
  app_service_plan_id        = azurerm_service_plan.this.id
  storage_account_name       = azurerm_storage_account.logic_app.name
  storage_account_access_key = azurerm_storage_account.logic_app.primary_access_key
  https_only                 = true
  enabled                    = true
  public_network_access      = "Disabled"
  virtual_network_subnet_id  = azurerm_subnet.integration.id
 
  app_settings = {
    "APPINSIGHTS_INSTRUMENTATIONKEY"        = azurerm_application_insights.this.instrumentation_key
    "APPLICATIONINSIGHTS_CONNECTION_STRING" = azurerm_application_insights.this.connection_string
    "WEBSITE_RUN_FROM_PACKAGE"              = "1"
    # Custom: reference a secret that only this Logic App needs
    "DOWNSTREAM_API_KEY" = "@Microsoft.KeyVault(VaultName=${azurerm_key_vault.this.name};SecretName=downstream-api-key)"
  }
 
  identity {
    type = "SystemAssigned"
  }
 
  site_config {
    always_on       = true
    min_tls_version = "1.2"
    ftps_state      = "Disabled"
    http2_enabled   = true
  }
 
  # Custom lifecycle: prevent accidental destroy in production
  lifecycle {
    prevent_destroy = true
    ignore_changes  = [app_settings["WEBSITE_RUN_FROM_PACKAGE"]]
  }
 
  tags = merge(var.tags, {
    hidden-title = "HTTP - Receive GitHub webhook and create JIRA ticket"
  })
 
  # The Key Vault must be accessible before the Logic App can start up and read its secrets
  depends_on = [
    azurerm_role_assignment.kv_secrets_user,
    azurerm_subnet.integration,
  ]
}

Workflow trigger standards (azurerm provider)

Triggers are the entry point for Logic App workflows. They define how and when a workflow executes - whether by external HTTP request, scheduled timer, or incoming message/event. Define triggers as separate Terraform resources using azurerm_logic_app_trigger_*.

Trigger positioning: Triggers do not have depends_on - they are root-level entry points that actions depend on. A workflow has exactly one active trigger. If you need multiple trigger types, create separate Logic Apps or use conditional branching after a single trigger.

Key decision: Trigger type selection

ScenarioTrigger TypeWhyExample
Manual invocation or external system webhooksHTTP RequestCaller controls when workflow runs; good for on-demand and integrationsGitHub webhook → create ticket
Recurring automated tasksRecurrenceFixed schedule; predictable cost and executionDaily threat hunt KQL query
Sentinel incident automationSentinel Incident (custom)Native incident data; direct role assignment in Standard Logic AppsEnrich and notify on incident creation
Event-driven (message queue)Service Bus (custom)Reliable message processing; automatic retryProcess incident queues
Reactive to resource eventsEvent Grid (custom)Event-driven without polling; push-basedStorage blob created → scan for malware
Third-party service eventsCustom webhookPlatform-specific integrationSlack message, Teams notification

HTTP Request trigger (manual invocation)

Use when: External systems need to invoke the workflow; caller controls execution time. Common in Sentinel playbooks, webhook integrations, or on-demand operations.

Considerations:

  • Requires schema definition to validate incoming requests (protects against malformed payloads)
  • Each HTTP trigger generates a unique callback URL; share this carefully (publish to only trusted callers)
  • Stateless execution makes HTTP triggers ideal for one-shot operations (no run history overhead)
  • Timeout: 120 seconds per request (Consumption) or configurable (Standard)

Avoid: Using HTTP trigger for scheduled tasks (use Recurrence instead); sending sensitive data in request body without encryption

HCL
resource "azurerm_logic_app_trigger_http_request" "http_trigger" {
  name         = "When_HTTP_request_is_received"
  logic_app_id = azurerm_logic_app_standard.this.id
  
  # Schema validates incoming request body - required
  schema = jsonencode({
    type       = "object"
    properties = {
      incident_id = {
        type        = "string"
        description = "Sentinel incident ID"
      }
      severity = {
        type        = "string"
        enum        = ["Low", "Medium", "High", "Critical"]
        description = "Incident severity level"
      }
      payload = {
        type       = "object"
        properties = {
          assets = { type = "array" }
          tags   = { type = "object" }
        }
      }
    }
    required = ["incident_id", "severity"]
  })
  
  method = "POST"
}

Recurrence trigger (scheduled execution)

Use when: Workflow runs on a fixed schedule - daily scans, hourly reports, weekly reviews. Good for automated threat hunting, compliance checks, or routine data synchronization.

Considerations:

  • start_time must be in ISO 8601 UTC format (e.g., 2024-01-01T08:00:00Z); do not use local time
  • Use time_zone = "UTC" to avoid DST confusion; if end-user sees times, convert client-side
  • Frequency + interval determine execution: frequency = "Day" + interval = 1 = every day; frequency = "Hour" + interval = 6 = every 6 hours
  • Recurrence runs at the specified time; if Logic App is down, it queues and runs when available
  • Cost: Fixed App Service Plan cost; no per-execution billing

Avoid: Recurrence intervals less than 1 minute (Azure limit); complex schedules (use multiple Logic Apps instead); hardcoded start times that become outdated

HCL
resource "azurerm_logic_app_trigger_recurrence" "schedule_trigger" {
  name         = "Recurrence_daily_enrichment"
  logic_app_id = azurerm_logic_app_standard.this.id
  
  frequency  = "Day"
  interval   = 1
  start_time = "2024-01-01T08:00:00Z"  # UTC only
  time_zone  = "UTC"
  
  # Optional: restrict to business days
  schedule {
    days_of_week = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
  }
}

Custom trigger - Sentinel Incident (native security automation)

Use when: Responding to Sentinel incident creation or updates. Automatically enriches, correlates, or escalates high-severity incidents without manual intervention.

Considerations:

  • Requires Sentinel connection (configured separately in Azure portal or via azurerm_api_connection)
  • Only available in Standard Logic Apps (Consumption requires manual trigger configuration)
  • Incident data is passed directly; no schema validation needed (Sentinel defines the structure)
  • Supports filtering by severity, product, or custom analytics rules
  • Fires synchronously; if workflow exceeds 1 hour, incident may be locked or escalated
  • Cost: Included in Standard Logic App plan (no per-incident charges)

Avoid: Logic Apps that take > 1 hour per incident (design for parallel execution); blocking on external API calls without timeout; hardcoding watchlist IDs or DCE immutable IDs (use Terraform variables instead)

HCL
# First, ensure the Sentinel connection exists (managed separately).
# Look up the managed API by name instead of hand-assembling its resource ID.
data "azurerm_managed_api" "sentinel" {
  name     = "azuresentinel"
  location = var.location
}
 
resource "azurerm_api_connection" "sentinel" {
  name                = "sentinel-${var.environment}"
  resource_group_name = azurerm_resource_group.this.name
  managed_api_id      = data.azurerm_managed_api.sentinel.id
}
 
# Sentinel trigger definition
resource "azurerm_logic_app_trigger_custom" "sentinel_incident" {
  name         = "When_Azure_Sentinel_incident_is_created_or_updated"
  logic_app_id = azurerm_logic_app_standard.this.id
  
  body = jsonencode({
    inputs = {
      host = {
        connection = {
          name = azurerm_api_connection.sentinel.id
        }
      }
      path = "/incident"
      queries = {
        # Filter by severity - do not hardcode
        "triggerBody()?['object']?['properties']?['severity']" = "High,Critical"
      }
    }
  })
}
 
# Example: Later action referencing the trigger
resource "azurerm_logic_app_action_custom" "enrich_incident" {
  name         = "Enrich_from_watchlist"
  logic_app_id = azurerm_logic_app_standard.this.id
  
  # Use Terraform-evaluated watchlist ID, not hardcoded string
  body = jsonencode({
    inputs = {
      watchlist_id = azurerm_sentinel_watchlist.threat_actors.immutable_id
      incident_id  = "@{triggerBody()?['object']?['id']}"
      severity     = "@{triggerBody()?['object']?['properties']?['severity']}"
    }
  })
  
  depends_on = [azurerm_logic_app_trigger_custom.sentinel_incident]
}

Custom trigger - Service Bus Queue (reliable message processing)

Use when: Incidents or alerts are queued in Service Bus; workflow processes them asynchronously. Good for decoupling producers from consumers, handling traffic spikes, and guaranteed delivery.

Considerations:

  • Service Bus provides automatic retry, dead-lettering, and message expiration
  • Polling interval: 30 seconds (configurable in Standard Logic Apps)
  • Scale horizontally: multiple Logic App instances pull from same queue without duplication
  • Messages auto-complete after processing; set isSessionEnabled = true for ordered message groups
  • Cost: Consumed as part of Standard plan; message charges apply

Avoid: Polling very frequently (< 30s causes unnecessary scaling); processing without error handling (unprocessed messages stay in queue); hardcoding queue names

HCL
# Service Bus connection
data "azurerm_managed_api" "service_bus" {
  name     = "servicebus"
  location = var.location
}
 
resource "azurerm_api_connection" "service_bus" {
  name                = "servicebus-${var.environment}"
  resource_group_name = azurerm_resource_group.this.name
  managed_api_id      = data.azurerm_managed_api.service_bus.id
}
 
# Queue trigger - references queue name from Terraform variable
resource "azurerm_logic_app_trigger_custom" "service_bus_trigger" {
  name         = "Service_Bus_message_incident_queue"
  logic_app_id = azurerm_logic_app_standard.this.id
  
  body = jsonencode({
    inputs = {
      host = {
        connection = {
          name = azurerm_api_connection.service_bus.id
        }
      }
      method = "GET"
      path   = "/queue/@{encodeURIComponent('${var.incident_queue_name}')}/messages"
      queries = {
        "maxMessagesToReturn" = 1
        "isSessionEnabled"    = false
      }
    }
  })
}

Custom trigger - Event Grid (event-driven automation)

Use when: Azure resources emit events (blob storage, Key Vault, Log Analytics); workflow reacts immediately without polling. Efficient for event-driven architectures.

Considerations:

  • Event Grid is push-based (not polling); immediate execution on event
  • Requires Event Grid subscription and topic configuration
  • Filters by event type (e.g., BlobCreated, SecretCreated, ResourceWriteSuccess)
  • Includes event metadata (timestamp, resource ID, properties)
  • Cost: Low cost per event; scales automatically

Avoid: Using Event Grid for internal app state changes (use Service Bus instead); not setting event filters (can overwhelm workflow)

HCL
# Event Grid connection
data "azurerm_managed_api" "event_grid" {
  name     = "azureeventgrid"
  location = var.location
}
 
resource "azurerm_api_connection" "event_grid" {
  name                = "eventgrid-${var.environment}"
  resource_group_name = azurerm_resource_group.this.name
  managed_api_id      = data.azurerm_managed_api.event_grid.id
}
 
# Event Grid trigger - blob storage events
resource "azurerm_logic_app_trigger_custom" "event_grid_trigger" {
  name         = "Event_Grid_blob_created"
  logic_app_id = azurerm_logic_app_standard.this.id
  
  body = jsonencode({
    inputs = {
      host = {
        connection = {
          name = azurerm_api_connection.event_grid.id
        }
      }
      # Reference storage account from Terraform - do not hardcode
      path = "/subscriptions/@{encodeURIComponent('${data.azurerm_client_config.current.subscription_id}')}/resourceGroups/@{encodeURIComponent('${azurerm_resource_group.this.name}')}/providers/Microsoft.Storage/storageAccounts/@{encodeURIComponent('${azurerm_storage_account.logs.name}')}/events"
      queries = {
        # Filter to blob created events only
        "api-version" = "2017-09-15-preview"
      }
    }
    schema = {
      type       = "object"
      properties = {
        eventType = {
          type = "string"
          enum = ["Microsoft.Storage.BlobCreated"]
        }
        data = {
          type = "object"
          properties = {
            url = { type = "string" }
          }
        }
      }
    }
  })
}

Trigger naming standards:

Trigger TypeNaming PatternExample
HTTP Request[verb]_http_requestwhen_http_request_is_received
Recurrencerecurrence_[frequency]_[purpose]recurrence_daily_enrichment, recurrence_hourly_scan
Sentinel Incidentsentinel_incident_[filter]sentinel_incident_high_critical, sentinel_incident_created
Service Busservice_bus_[queue/topic]_messageservice_bus_incident_queue_message
Event Gridevent_grid_[resource_type]event_grid_blob_created, event_grid_key_vault_secret
Custom Connector[service]_[event_type]teams_message_received, slack_event_posted

Trigger best practices (Terraform):

  1. No depends_on on triggers - Triggers are root entry points; actions depend on triggers via implicit trigger body references
  2. Sentinel triggers in Standard Logic Apps - Allows direct Sentinel role assignment and private resource access
  3. Recurrence triggers - always use UTC - Avoid timezone confusion; use time_zone = "UTC" with fixed start_time (ISO 8601 format)
  4. HTTP triggers - require schema - Document expected body structure; clients must validate against schema
  5. Custom triggers - use connection references - Never hardcode credentials; reference azurerm_api_connection resources
  6. Polling intervals - Service Bus (30s), Event Grid (push), Recurrence (configurable)

Example: Complete trigger + action dependency chain:

HCL
# Trigger - entry point, no depends_on
resource "azurerm_logic_app_trigger_recurrence" "daily_scan" {
  name         = "Recurrence_daily_threat_hunt"
  logic_app_id = azurerm_logic_app_standard.this.id
  frequency    = "Day"
  interval     = 1
  start_time   = "2024-01-01T09:00:00Z"
  time_zone    = "UTC"
}
 
# Action - depends on trigger via trigger body
resource "azurerm_logic_app_action_http" "kql_query" {
  name         = "HTTP_execute_kql_query"
  logic_app_id = azurerm_logic_app_standard.this.id
  method       = "POST"
  # Host is the shared api.loganalytics.io endpoint; the path takes the workspace
  # GUID (customer ID) from .workspace_id, NOT the full ARM resource .id.
  uri          = "https://api.loganalytics.io/v1/workspaces/${azurerm_log_analytics_workspace.this.workspace_id}/query"
  
  depends_on = [azurerm_logic_app_trigger_recurrence.daily_scan]
}
 
# Action - depends on previous action
resource "azurerm_logic_app_action_custom" "parse_results" {
  name         = "Parse_KQL_results"
  logic_app_id = azurerm_logic_app_standard.this.id
  # ...
  depends_on = [azurerm_logic_app_action_http.kql_query]
}

Workflow action standards (azurerm provider)

Workflow actions are defined as separate Terraform resources using azurerm_logic_app_action_*. This approach provides explicit dependency management, clear action ordering, and testability. Actions execute sequentially based on depends_on (or conditionally via runAfter in the action body).

Provider reality check: The azurerm provider exposes exactly two Logic App action resources - azurerm_logic_app_action_http and azurerm_logic_app_action_custom - plus three trigger resources (_trigger_http_request, _trigger_recurrence, _trigger_custom). There is no _action_scope, _action_condition, _action_for_each, _action_initialize_variable, or _action_parse_json resource. Every action other than a plain HTTP call is authored as an azurerm_logic_app_action_custom whose body is the action’s JSON definition, with its type set accordingly (Scope, If, Foreach, InitializeVariable, ParseJson, ApiConnection, etc.). The examples below follow this rule.

Action selection and dependencies

Action TypeUse CaseWhenAvoid
HTTPREST API calls, webhooks, external integrationsNeed external system data; calling internal microservicesFor internal Logic App state; hardcoding API keys
CustomConnector-based operations (Jira, Teams, Service Bus)Using platform-specific connectorsReinventing connector functionality with HTTP
ScopeGrouped actions with error handling (try/catch)Multiple actions with shared error handlerSingle action (overhead not justified)
ConditionBranching logic (if/else)Multi-path workflows based on dataComplex decision trees (use Scope instead)
For EachIterating over arraysProcessing multiple incidents, alerts, recipientsIterating strings (use split() + For Each)
Initialize VariableDeclare workflow-scoped variablesNeed persistent state across actionsHardcoding values; use variables for reusability

Terraform best practices for actions

  1. No hardcoding deferred values - Never include Terraform-evaluated IDs in action bodies as strings. Reference them directly:

    HCL
    # WRONG - hardcoded ID becomes stale when DCE is recreated
    body = jsonencode({
      dce_immutable_id = "/subscriptions/.../dces/my-dce-12345"
    })
     
    # CORRECT - Terraform ensures ID is current
    body = jsonencode({
      dce_immutable_id = azurerm_monitor_data_collection_endpoint.this.immutable_id
    })
  2. jsonencode() for all JSON - Always use jsonencode() to prevent quote escaping issues and ensure valid JSON syntax.

  3. Variables for reusable values - Store queue names, API endpoints, watchlist names as Terraform variables:

    HCL
    # In variables.tf
    variable "incident_queue_name" {
      type = string
      default = "incident-queue"
    }
     
    # In action body
    path = "/queue/@{encodeURIComponent('${var.incident_queue_name}')}/messages"
  4. Connection references - Always reference azurerm_api_connection resources, never hardcode connection IDs:

    HCL
    host = {
      connection = {
        name = azurerm_api_connection.sentinel.id
      }
    }

Initialize Variable (root action - no depends_on)

Use when: Declaring workflow-scoped variables that multiple actions will reference. Initialize early; variables persist across entire workflow run.

Avoid: Declaring variables late (actions before initialization can’t reference them); storing sensitive data (use Key Vault references instead)

HCL
resource "azurerm_logic_app_action_custom" "initialize_variables" {
  name                    = "Initialize_variables"
  logic_app_id            = azurerm_logic_app_standard.this.id
  
  # type = "InitializeVariable" makes this a variable-init action, not an HTTP call.
  body = jsonencode({
    type = "InitializeVariable"
    inputs = {
      variables = [
        {
          name  = "incident_id"
          type  = "String"
          value = "@{triggerBody()?['object']?['id']}"
        },
        {
          name  = "severity"
          type  = "String"
          value = "@{triggerBody()?['object']?['properties']?['severity']}"
        },
        {
          name  = "enrichment_api_key"
          type  = "String"
          # Reference Key Vault secret - never hardcode API keys
          value = "@{parameters('enrichment_api_key')}"
        }
      ]
    }
    runAfter = {}
  })
}

HTTP action (external API calls)

Use when: Calling external REST APIs, webhooks, or enrichment services. Synchronous calls for data retrieval.

Considerations:

  • Timeout: 30 seconds (standard); use timeout = "PT5M" for longer operations
  • Retry: Set to 3 attempts with exponential backoff for transient failures
  • Headers: Always set Content-Type; use parameters for authentication (not hardcoded)
  • Fail-safe: Wrap in Scope block if API failure shouldn’t stop entire workflow

Avoid: Hardcoding API endpoints (use variables); hardcoding credentials (use Key Vault parameters); not handling API errors

HCL
# Store API endpoint in variable (can change per environment)
variable "enrichment_api_endpoint" {
  type = string
  default = "https://api.example.com/incidents/enrich"
}
 
resource "azurerm_logic_app_action_http" "webhook_call" {
  name                    = "HTTP_webhook_enrichment"
  logic_app_id            = azurerm_logic_app_standard.this.id
  method                  = "POST"
  uri                     = var.enrichment_api_endpoint  # From Terraform variable
  headers = {
    "Content-Type"      = "application/json"
    "Authorization"     = "Bearer @{parameters('enrichment_api_key')}"  # From Key Vault
    "X-Request-ID"      = "@{guid()}"  # Unique request ID for tracking
  }
  
  body = jsonencode({
    incident_id = "@{variables('incident_id')}"
    severity    = "@{variables('severity')}"
    # Include enrichment context - reference from watchlist via Terraform
    threat_intel_source = var.threat_intel_watchlist_name
  })
  
  depends_on = [azurerm_logic_app_action_custom.initialize_variables]
}

Custom connector action (platform-specific operations)

Use when: Using Jira, Teams, Service Bus, Log Analytics, or other managed connectors. Avoid reimplementing connector logic with HTTP.

Considerations:

  • Connector must be pre-registered as azurerm_api_connection
  • Each connector has specific operation IDs (e.g., CreateIssue, SendMessage, QueryLogs)
  • Retry: Managed by connector (usually 2 attempts); HTTP actions retry separately
  • Output: Connector action output is available to subsequent actions

Avoid: Using HTTP for operations that have managed connectors available (maintainability nightmare); hardcoding project keys or queue names

HCL
# First, register the connector (manages authentication)
data "azurerm_managed_api" "jira" {
  name     = "jira"
  location = var.location
}
 
resource "azurerm_api_connection" "jira" {
  name                = "jira-${var.environment}"
  resource_group_name = azurerm_resource_group.this.name
  managed_api_id      = data.azurerm_managed_api.jira.id
}
 
# Then use in custom action - reference connector and store project key as variable
variable "jira_security_project_key" {
  type    = string
  default = "SEC"
}
 
resource "azurerm_logic_app_action_custom" "create_jira_ticket" {
  name             = "JIRA_create_issue"
  logic_app_id     = azurerm_logic_app_standard.this.id
  
  body = jsonencode({
    inputs = {
      host = {
        connection = {
          name = azurerm_api_connection.jira.id
        }
      }
      method   = "post"
      path     = "/rest/api/3/issues"
      queries = {
        # Project key from Terraform variable
        projectKey = var.jira_security_project_key
      }
      body = {
        fields = {
          summary     = "@{variables('incident_id')} - @{variables('severity')}"
          description = "@{body('HTTP_webhook_enrichment')?['description']}"
          issuetype = {
            # Map severity to issue type
            name = "@{if(equals(variables('severity'), 'Critical'), 'Security Incident', 'Task')}"
          }
          # Reference custom field from Terraform if needed
          customfield_10000 = var.jira_incident_priority_field_id
        }
      }
    }
    runAfter = {
      "HTTP_webhook_enrichment" = ["Succeeded"]
    }
  })
  
  depends_on = [azurerm_logic_app_action_http.webhook_call]
}

Scope block (try/catch error handling)

Use when: Grouping multiple actions that share error handling. Scope catches failures from child actions and allows conditional error handler logic.

Considerations:

  • Scope succeeds only if all children succeed; any child failure marks scope as failed
  • Error handler Scope runs on parent failure (runAfter: ["Failed", "TimedOut"])
  • Multiple Scopes can execute in parallel if not dependent
  • Use for enrichment operations, data validation, or external API sequences

Avoid: Nesting Scopes > 2 levels deep (complex error logic); empty Scopes (use single action instead)

HCL
# Try block - enrichment operations
resource "azurerm_logic_app_action_custom" "scope_enrichment" {
  name         = "Scope_enrichment"
  logic_app_id = azurerm_logic_app_standard.this.id
 
  # A Scope is a single custom action whose body nests its child actions
  # under "actions". type = "Scope" is what makes it a scope, not an HTTP call.
  body = jsonencode({
    type = "Scope"
    actions = {
      "Parse_enrichment_response" = {
        type = "ParseJson"
        inputs = {
          content = "@body('HTTP_webhook_enrichment')"
          schema = {
            type = "object"
            properties = {
              risk_score = { type = "number" }
              assets     = { type = "array" }
            }
          }
        }
        runAfter = {}
      }
      "Update_incident_tags" = {
        type = "ApiConnection"
        inputs = {
          host = {
            connection = { name = azurerm_api_connection.sentinel.id }
          }
          method = "patch"
          path   = "/incidents/@{variables('incident_id')}"
          body = {
            # Pass enriched assets from webhook response
            tags = "@{body('Parse_enrichment_response')?['assets']}"
          }
        }
        runAfter = {
          "Parse_enrichment_response" = ["Succeeded"]
        }
      }
    }
    runAfter = {}
  })
 
  depends_on = [azurerm_logic_app_action_http.webhook_call]
}
 
# Catch block - runs only if scope_enrichment fails
resource "azurerm_logic_app_action_custom" "scope_error_handler" {
  name         = "Scope_error_handler"
  logic_app_id = azurerm_logic_app_standard.this.id
 
  body = jsonencode({
    type = "Scope"
    actions = {
      "Log_error_to_law" = {
        type = "ApiConnection"
        inputs = {
          host = {
            connection = { name = azurerm_api_connection.log_analytics.id }
          }
          method = "post"
          # Reference DCE immutable ID from Terraform - not hardcoded
          path = "/api/logs?api-version=2016-04-01&dce=${azurerm_monitor_data_collection_endpoint.this.immutable_id}"
          body = {
            TimeGenerated = "@utcNow()"
            SourceSystem  = "LogicApp"
            Message       = "@{string(outputs('Scope_enrichment'))}"
            IncidentId    = "@{variables('incident_id')}"
            Severity      = "@{variables('severity')}"
          }
        }
        runAfter = {}
      }
      "Alert_team_enrichment_failure" = {
        type = "ApiConnection"
        inputs = {
          host = {
            connection = { name = azurerm_api_connection.teams.id }
          }
          method = "post"
          path   = "/webhooks"
          body = {
            text = "Enrichment failed for incident @{variables('incident_id')} (@{variables('severity')}). Check logs."
          }
        }
        runAfter = {
          "Log_error_to_law" = ["Succeeded"]
        }
      }
    }
    # This scope runs only when the enrichment scope fails or times out.
    runAfter = {
      "Scope_enrichment" = ["Failed", "TimedOut"]
    }
  })
 
  # Error handler runs when scope_enrichment fails (or times out)
  depends_on = [azurerm_logic_app_action_custom.scope_enrichment]
}

Action dependency chain example:

HCL
# Dependency flow:
# Initialize_variables
#   ├─ HTTP_webhook_enrichment
#   │   ├─ JIRA_create_issue
#   │   └─ Scope_enrichment
#   │       └─ Scope_error_handler (on failure)
#   └─ Condition_severity_check
#       └─ For_each_recipients
 
resource "azurerm_logic_app_action_custom" "initialize_variables" {
  # ... (see above, no depends_on)
}
 
resource "azurerm_logic_app_action_http" "webhook_call" {
  depends_on = [azurerm_logic_app_action_custom.initialize_variables]
  # ...
}
 
resource "azurerm_logic_app_action_custom" "create_jira_ticket" {
  depends_on = [azurerm_logic_app_action_http.webhook_call]
  # ...
}
 
resource "azurerm_logic_app_action_custom" "scope_enrichment" {
  depends_on = [azurerm_logic_app_action_http.webhook_call]
  # ...
}
 
resource "azurerm_logic_app_action_custom" "scope_error_handler" {
  depends_on = [azurerm_logic_app_action_custom.scope_enrichment]
  # Only runs on failure (express in body's runAfter)
}
 
resource "azurerm_logic_app_action_custom" "severity_check" {
  depends_on = [azurerm_logic_app_action_custom.initialize_variables]
  # ...
}
 
resource "azurerm_logic_app_action_custom" "notify_recipients" {
  depends_on = [azurerm_logic_app_action_custom.severity_check]
  # ...
}

Action naming standards:

Resource TypeNaming PatternExample
HTTPhttp_[verb]_[service]http_post_webhook, http_get_sentinel
Custom[service]_[operation]jira_create_issue, service_bus_send_message
Scopescope_[operation]scope_enrichment, scope_error_handler
Conditioncondition_[check]condition_severity_check
For Eachfor_each_[items]for_each_recipients, for_each_alerts
Initializeinitialize_[variable_name]initialize_variables, initialize_counter
Parseparse_[format]_[source]parse_json_response, parse_xml_body

Role assignments (always in Terraform, never manual)

Cookie-cutter path - referencing the module output:

HCL
# The map key matches the name value in the logic_apps input list.
resource "azurerm_role_assignment" "sentinel_responder" {
  scope                = data.azurerm_resource_group.sentinel.id
  role_definition_name = "Microsoft Sentinel Responder"
  principal_id         = module.logic_apps.logic_app_identities["logic-ldo-uks-prd-01"][0].principal_id
}

Custom path - referencing the raw resource directly:

HCL
resource "azurerm_role_assignment" "sentinel_responder" {
  scope                = data.azurerm_resource_group.sentinel.id
  role_definition_name = "Microsoft Sentinel Responder"
  principal_id         = azurerm_logic_app_standard.this.identity[0].principal_id
 
  # principal_id is an attribute of the Logic App, so Terraform infers the dependency.
  # The explicit depends_on is belt-and-braces for the provisioning race condition
  # described in the Key Vault section above.
  depends_on = [azurerm_logic_app_standard.this]
}
 
resource "azurerm_role_assignment" "law_reader" {
  scope                = azurerm_log_analytics_workspace.this.id
  role_definition_name = "Log Analytics Reader"
  principal_id         = azurerm_logic_app_standard.this.identity[0].principal_id
  depends_on           = [azurerm_logic_app_standard.this]
}
 
resource "azurerm_role_assignment" "kv_secrets_user" {
  scope                = azurerm_key_vault.this.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_logic_app_standard.this.identity[0].principal_id
  depends_on           = [azurerm_logic_app_standard.this]
}

Storage account (Standard)

Standard Logic Apps require a dedicated storage account for stateful workflow state, run history, and queue scheduling.

HCL
resource "azurerm_storage_account" "logic_app" {
  name                     = "saldouksprd01"   # no hyphens, max 24 chars
  resource_group_name      = azurerm_resource_group.this.name
  location                 = var.location
  account_tier             = "Standard"
  account_replication_type = "ZRS"              # Zone-redundant for production
  min_tls_version          = "TLS1_2"
  https_traffic_only_enabled = true
  public_network_access_enabled = false         # Use private endpoint or service endpoint
 
  network_rules {
    default_action             = "Deny"
    virtual_network_subnet_ids = [azurerm_subnet.integration.id]
  }
 
  tags = var.tags
}

Rule: Never share the Logic App storage account with other workloads. The storage account handles internal state queuing; unexpected IOPS from another workload can cause workflow execution failures.

Diagnostic settings

HCL
resource "azurerm_monitor_diagnostic_setting" "logic_app" {
  for_each = module.logic_apps.logic_app_ids
 
  name                       = "diag-${each.key}"
  target_resource_id         = each.value
  log_analytics_workspace_id = azurerm_log_analytics_workspace.this.id
 
  enabled_log { category = "WorkflowRuntime" }
  metric { category = "AllMetrics" }
}

Dependency Chains

When Logic Apps, API connections, role assignments, access policies, storage accounts, and Sentinel automation rules are provisioned together, the order matters. Terraform infers most dependencies automatically from attribute references (some_resource.id), but several relationships in the Logic App stack cannot be inferred - the body of an azapi_resource or a depends_on is needed to express them explicitly.

Correct creation order

PLAINTEXT
1.  Resource Group
2.  Storage Account          (Standard only - Logic App cannot start without it)
3.  App Service Plan         (Standard only)
4.  User-assigned Identity   (if using shared permission model)
5.  Role Assignments         (on the identity - must exist before App Settings can read KV)
6.  Logic App                (depends on storage, plan, and KV role assignment)
7.  API Connection resources (Sentinel, Teams - depends_on Logic App)
8.  Connection Access Policies (depends_on connection + Logic App identity)
9.  Diagnostic Settings      (depends on Logic App ID)
10. Sentinel Automation Rule (depends on Logic App - references its resource ID)

Terraform depends_on chain - full example

HCL
# ── 1. Storage (Standard Logic App prerequisite) ───────────────────────────
resource "azurerm_storage_account" "logic_app" {
  name                     = "saldouksprd01"
  resource_group_name      = azurerm_resource_group.this.name
  location                 = var.location
  account_tier             = "Standard"
  account_replication_type = "ZRS"
  min_tls_version          = "TLS1_2"
  https_traffic_only_enabled    = true
  public_network_access_enabled = false
 
  network_rules {
    default_action             = "Deny"
    virtual_network_subnet_ids = [azurerm_subnet.integration.id]
  }
 
  tags = var.tags
}
 
# ── 2. App Service Plan ─────────────────────────────────────────────────────
resource "azurerm_service_plan" "this" {
  name                = "plan-ldo-uks-prd-01"
  resource_group_name = azurerm_resource_group.this.name
  location            = var.location
  os_type             = "Linux"
  sku_name            = "WS1"
  tags                = var.tags
}
 
# ── 3. Key Vault role assignment ────────────────────────────────────────────
# Must be applied BEFORE the Logic App is created.
# The Logic App reads Key Vault references in App Settings on startup.
# If the role does not exist when the Logic App first starts, startup fails.
resource "azurerm_role_assignment" "kv_secrets_user" {
  scope                = azurerm_key_vault.this.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_logic_app_standard.this.identity[0].principal_id
 
  # Chicken-and-egg: the principal_id comes from the Logic App, but we need the
  # role before the Logic App can boot. Terraform creates the Logic App first
  # (because principal_id is its attribute), then creates the role.
  # This is acceptable - the Logic App will retry KV reads on first workflow run.
  # If a hard guarantee is needed, use a User-assigned identity whose role
  # assignment can be applied fully before the Logic App is created.
}
 
# ── 4. Logic App ────────────────────────────────────────────────────────────
resource "azurerm_logic_app_standard" "this" {
  name                       = "logic-ldo-uks-prd-01"
  resource_group_name        = azurerm_resource_group.this.name
  location                   = var.location
  app_service_plan_id        = azurerm_service_plan.this.id
  storage_account_name       = azurerm_storage_account.logic_app.name
  storage_account_access_key = azurerm_storage_account.logic_app.primary_access_key
  https_only                 = true
  virtual_network_subnet_id  = azurerm_subnet.integration.id
 
  identity {
    type = "SystemAssigned"
  }
 
  app_settings = {
    "APPINSIGHTS_INSTRUMENTATIONKEY" = azurerm_application_insights.this.instrumentation_key
    "DOWNSTREAM_API_KEY"             = "@Microsoft.KeyVault(VaultName=${azurerm_key_vault.this.name};SecretName=downstream-api-key)"
  }
 
  site_config {
    always_on       = true
    min_tls_version = "1.2"
    ftps_state      = "Disabled"
  }
 
  tags = merge(var.tags, {
    "hidden-title" = "Sentinel Incident - Enrich on High severity"
  })
 
  # Explicit: storage and plan are inferred via attribute refs.
  # Subnet must exist before VNet integration is configured.
  depends_on = [azurerm_subnet.integration]
}
 
# ── 5. Sentinel role assignment ─────────────────────────────────────────────
resource "azurerm_role_assignment" "sentinel_responder" {
  scope                = data.azurerm_resource_group.sentinel.id
  role_definition_name = "Microsoft Sentinel Responder"
  principal_id         = azurerm_logic_app_standard.this.identity[0].principal_id
  # Implicit depends_on: principal_id is an attribute of azurerm_logic_app_standard.this
}
 
resource "azurerm_role_assignment" "law_reader" {
  scope                = azurerm_log_analytics_workspace.this.id
  role_definition_name = "Log Analytics Reader"
  principal_id         = azurerm_logic_app_standard.this.identity[0].principal_id
}
 
# ── 6. API connections (azapi) ──────────────────────────────────────────────
resource "azapi_resource" "sentinel_connection" {
  type      = "Microsoft.Web/connections@2016-06-01"
  name      = "conn-sentinel-ldo-uks-prd-01"
  location  = var.location
  parent_id = "/subscriptions/${var.subscription_id}/resourceGroups/${var.rg_name}"
 
  body = {
    properties = {
      displayName        = "Microsoft Sentinel - Managed Identity"
      parameterValueType = "Alternative"
      api = {
        id = "/subscriptions/${var.subscription_id}/providers/Microsoft.Web/locations/${var.location}/managedApis/azuresentinel"
      }
    }
  }
 
  response_export_values = ["*"]
 
  # Connection can be created in parallel with the Logic App - no dependency needed here
}
 
# ── 7. Access policy ────────────────────────────────────────────────────────
# Must wait for: the connection, the Logic App identity, AND the Sentinel role assignment.
# Terraform cannot infer the role assignment dependency - the access policy body
# contains the principal_id as a plain string, not a resource attribute reference.
resource "azapi_resource" "sentinel_connection_access_policy" {
  type      = "Microsoft.Web/connections/accessPolicies@2016-06-01"
  name      = azurerm_logic_app_standard.this.name
  parent_id = azapi_resource.sentinel_connection.id
  location  = var.location
 
  body = {
    properties = {
      principal = {
        type = "ActiveDirectory"
        identity = {
          objectId = azurerm_logic_app_standard.this.identity[0].principal_id
          tenantId = var.tenant_id
        }
      }
    }
  }
 
  depends_on = [
    azapi_resource.sentinel_connection,       # connection must exist first
    azurerm_logic_app_standard.this,          # principal_id must be known
    azurerm_role_assignment.sentinel_responder, # role must be applied - the access policy
                                               # authorises use of the connection; if the
                                               # role doesn't exist yet, the first workflow
                                               # run fails even though the policy exists
  ]
}
 
# ── 8. Diagnostic settings ──────────────────────────────────────────────────
resource "azurerm_monitor_diagnostic_setting" "logic_app" {
  name                       = "diag-logic-ldo-uks-prd-01"
  target_resource_id         = azurerm_logic_app_standard.this.id
  log_analytics_workspace_id = azurerm_log_analytics_workspace.this.id
 
  enabled_log { category = "WorkflowRuntime" }
  metric { category = "AllMetrics" }
 
  # Implicit depends_on via target_resource_id
}
 
# ── 9. Sentinel Automation Rule ─────────────────────────────────────────────
resource "azurerm_sentinel_automation_rule" "high_severity_playbook" {
  name                       = "ar-high-severity-enrich-ldo-uks-prd"
  log_analytics_workspace_id = azurerm_log_analytics_workspace.this.id
  display_name               = "High Severity - Run enrichment playbook"
  order                      = 100
  enabled                    = true
 
  condition {
    property  = "IncidentSeverity"
    operator  = "Equals"
    values    = ["High"]
  }
 
  action_playbook {
    logic_app_id            = azurerm_logic_app_standard.this.id
    tenant_id               = var.tenant_id
    order                   = 1
  }
 
  depends_on = [
    azurerm_logic_app_standard.this,             # must exist before rule can reference it
    azapi_resource.sentinel_connection_access_policy, # connection auth must be in place
    azurerm_role_assignment.sentinel_responder,  # Logic App must have Sentinel access
  ]
}

Common ordering failures

FailureRoot causeFix
Automation Rule creation fails - Logic App not foundAutomation rule was applied before Logic App was fully createdAdd depends_on = [azurerm_logic_app_standard.this] to the automation rule
Logic App fails to start - cannot read Key Vault secretKey Vault role assignment not applied before Logic App first bootUse a User-assigned identity whose role assignment is applied fully before the Logic App is created; or accept the retry behaviour
Access policy returns 403 on first workflow runRole assignment not complete when the access policy was createdAdd the role assignment resource to the access policy depends_on
API connection auth fails - “connection not authorised”Access policy was not created, or was created before the identity existedEnsure depends_on on the access policy includes both the Logic App and the role assignments
Automation rule silently not triggeringLogic App Contributor role missing on the Logic App itselfAssign Logic App Contributor to the Sentinel workspace managed identity on the Logic App resource

When to use explicit depends_on vs attribute references

Prefer attribute references (e.g. principal_id = azurerm_logic_app_standard.this.identity[0].principal_id) over depends_on wherever possible - attribute references create precise, minimal dependencies and preserve parallelism.

Use explicit depends_on only when:

  • A resource must wait for another to be fully applied, but shares no attribute reference with it
  • An azapi_resource body contains values derived from Terraform resources as literal strings (not references), so Terraform cannot infer the dependency
  • A side effect must complete before the next resource is created (e.g. a role assignment must be active - not just planned - before an access policy is created)

Avoid long depends_on chains on modules - they force the entire module (all its resources) to complete before anything in the depends_on can start, collapsing all parallelism. Pass specific resource IDs as module inputs instead.


Observability

Run history

Logic App run history is stored for:

  • Consumption: 90 days
  • Standard stateful: 90 days (default, configurable)
  • Standard stateless: No run history stored (by design - use Application Insights)

For Standard stateless workflows, enable Application Insights to capture run data:

HCL
app_settings = {
  "APPINSIGHTS_INSTRUMENTATIONKEY"  = azurerm_application_insights.this.instrumentation_key
  "APPLICATIONINSIGHTS_CONNECTION_STRING" = azurerm_application_insights.this.connection_string
}

Diagnostic settings → Log Analytics

Send workflow runtime logs to Log Analytics for querying and alerting:

Log categoryWhat it captures
WorkflowRuntimeAll action executions, trigger fires, run outcomes
AllMetricsAction latency, success/failure counts, throttling

Query failed runs:

KQL
AzureDiagnostics
| where ResourceType == "WORKFLOWS"
| where status_s == "Failed"
| where TimeGenerated > ago(24h)
| project TimeGenerated, resource_workflowName_s, status_s, error_code_s, error_message_s
| sort by TimeGenerated desc

Alerting on failed runs

HCL
resource "azurerm_monitor_metric_alert" "logic_app_failures" {
  for_each = module.logic_apps.logic_app_ids
 
  name                = "alert-${each.key}-failures"
  resource_group_name = var.rg_name
  scopes              = [each.value]
  severity            = 1   # Critical
  frequency           = "PT5M"
  window_size         = "PT15M"
 
  criteria {
    metric_namespace = "Microsoft.Logic/workflows"
    metric_name      = "RunsFailed"
    aggregation      = "Total"
    operator         = "GreaterThan"
    threshold        = 0
  }
 
  action {
    action_group_id = azurerm_monitor_action_group.ops.id
  }
}

Portal integration

From Sentinel to the Logic App:

  • Sentinel → Automation → Automation rules → rule → Playbook link
  • Sentinel → Automation → Playbooks → logic app name → runs

From the Logic App to Sentinel:

  • Logic App → Overview → Trigger history (see each firing)
  • Logic App → Monitoring → Run history (see each run’s action-by-action trace)
  • Logic App → Monitoring → Alerts

Designer vs Code view:

  • The visual designer is useful for understanding flow but is not the source of truth in a Terraform-managed deployment
  • The workflow JSON (workflow.json for Standard, or the ARM template for Consumption) is the source of truth
  • Changes made in the designer will drift from Terraform state - always make workflow logic changes via code, not the portal designer

Anti-patterns

  • User account API connections in production - user connections break on password changes, MFA prompts, and when the account is disabled or leaves the organisation. Always use Managed Identity for Microsoft services and Service Principals with Key Vault-stored secrets for external services.
  • Using azurerm_resource_group_template_deployment for API connections - ARM deployments wrapped in Terraform are opaque: they don’t surface the connection state in terraform plan, errors are buried in ARM deployment logs, and the deployment JSON is hard to read and review. Use azapi_resource instead - it is first-class Terraform, shows in the plan, and can be referenced as a resource.
  • Not creating connection access policies - creating the Microsoft.Web/connections resource is not sufficient. Without an access policy that maps the Logic App’s managed identity to the connection, the connection will fail at runtime with a 403 even though the connection resource exists.
  • Not filtering the Sentinel Incident trigger - the trigger fires on both creation and every update. Without a condition immediately after the trigger, your playbook fires repeatedly on every enrichment, comment, or status change, creating duplicate notifications and potentially closing incidents prematurely.
  • Using a module for non-cookie-cutter Logic Apps - if the Logic App has custom lifecycle rules, unusual networking, per-resource storage, or complex cross-resource dependencies, a module abstraction forces you to work around it. Write the raw resources explicitly - the verbosity is the documentation.
  • Relying on Consumption Logic App outbound IPs for allowlisting - the shared IP ranges are large, rotate without notice, and allowlisting them permits all Consumption Logic Apps in the region, not just yours. Use identity-based auth, or switch to Standard with a dedicated IP.
  • Retrying non-idempotent actions - default retry policies retry Teams notifications and email sends on transient failures, resulting in duplicate messages. Set retryPolicy: { type: none } on all actions that have visible side effects.
  • No Terminate (Failed) action in error handlers - without it, the workflow run reports Succeeded even when the error handler fired. Silent failures cannot be alerted on. Always end error scope blocks with Terminate: Failed.
  • Managing workflow JSON through the portal designer - any portal change will drift from Terraform state and be overwritten on the next apply. Manage all workflow logic in code. Use the portal designer for exploration and debugging only.
  • Sharing the Logic App storage account - the storage account provides internal state queuing for the Logic App runtime. Other workloads generating storage I/O can cause queue processing delays and workflow execution failures. Always use a dedicated storage account.
  • Deploying the Sentinel Automation Rule before the Logic App - the automation rule references the Logic App resource ID. Creating it before the Logic App exists will fail, and a later apply may silently skip recreating it. Use depends_on on the automation rule.
  • Not setting hidden-title on every Logic App - the resource name is opaque by design. Without hidden-title, operators cannot identify what a Logic App does without opening it individually.
  • Stateless workflows for Sentinel playbooks - stateless workflows have a 5-minute execution limit and no run history. Sentinel enrichment playbooks calling multiple connectors sequentially frequently exceed 5 minutes under load. Use stateful unless you have measured that 5 minutes is always sufficient.

See Also

Last updated on