Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Logic App Custom Trigger Terraform Module

Starts a Logic App workflow from a trigger defined as raw JSON (azurerm_logic_app_trigger_custom) — the escape hatch for connector, Service Bus, and Event Grid triggers. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources


🧩 Overview

  • 🚪 Starts an existing Logic App workflow from a raw JSON trigger definition, as one keystone resource named this.
  • 🔌 The escape hatch for triggers the provider does not model — a connector poll, a Service Bus queue, an Event Grid subscription, a sliding window.
  • 🔑 Emits callback_url as sensitive even though the provider does not mark it, and even though it is empty for most trigger bodies.
  • ⚠️ States plainly that Terraform checks only that the body is well-formed JSON — StringIsJSON runs at plan — and validates nothing about its meaning.
  • 🧭 Points at terraform-azurerm-logic-app-trigger-recurrence and -http-request where typed alternatives exist.
  • 🤝 Documents the connector reality: an API connection's consent flow is interactive and Terraform cannot complete it.

💡 Why it matters: this module trades type safety for reach, and the reach is genuinely needed — most real Logic App triggers are connector-based and have no typed resource. The two things worth internalising are that nothing in the body is checked, and that a first apply can leave you with a trigger that exists and never fires because its connection was never authorized.

❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

flowchart LR
  wf["terraform-azurerm-logic-app-workflow"]
  this["terraform-azurerm-logic-app-trigger-custom"]
  trg["azurerm_logic_app_trigger_custom"]
  typed["terraform-azurerm-logic-app-trigger-recurrence or -http-request"]
  upstream["Service Bus, Event Grid, or storage the body watches"]
  conn["an API connection, authorized out of band"]
  act["terraform-azurerm-logic-app-action-http"]

  wf -->|"logic_app_id"| this
  typed -->|"preferred where a typed trigger exists"| this
  this -->|"creates"| trg
  conn -->|"referenced by name in the body"| trg
  upstream -->|"watched by a connector trigger"| trg
  trg -->|"starts the run"| act

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class trg keystone;
  class wf,typed,upstream,conn,act sib;
Loading

🧬 What this module builds

flowchart TB
  name["name: unique across all triggers, force-new"]
  wf["logic_app_id: force-new"]
  body["body: opaque JSON trigger definition"]
  guard["no plan-time validation of the document"]
  this["terraform-azurerm-logic-app-trigger-custom"]
  trg["azurerm_logic_app_trigger_custom.this"]
  url["callback_url: sensitive, empty for non-request triggers"]
  out["outputs: id, name, logic_app_id"]

  name -->|"identity"| this
  wf -->|"parent workflow"| this
  body -->|"the trigger itself"| this
  guard -->|"prefer a typed trigger module"| body
  this -->|"creates"| trg
  trg -->|"populated only for request-style bodies"| url
  trg -->|"emits"| out

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class trg keystone;
  class name,wf,body,guard,url,out sib;
Loading

Resource inventory

Resource Count Role
azurerm_logic_app_trigger_custom.this 1 The keystone trigger, with its timeouts block.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription.

Schema notes that bite (verified against the live provider schema):

  • name and logic_app_id are force-new.
  • name must be unique across every trigger in the workflow, and the provider cannot check that. In practice a Logic App has exactly one trigger, so a second trigger module pointed at the same workflow is almost always a mistake.
  • The id is unique to Terraform: workflow ID + /triggers/<name>, matching no identifier the Azure APIs return.
  • Terraform checks only that body is well-formed JSON — StringIsJSON runs at plan — and validates nothing about its meaning. A malformed trigger, a wrong type, or a reference to a non-existent connection all fail at apply or at run time, never at plan.
  • callback_url is populated only for trigger types invoked by request and is empty for everything else. An empty value is normal, not a failure — but where it is populated it is a bearer credential, and the provider does not mark it sensitive.
  • A connector-based trigger body typically depends on an API connection whose authorization is interactive. Terraform can create the connection resource but cannot complete the consent, so a first apply may produce a trigger that exists and does not fire.
  • Triggers and actions written as separate resources against one workflow update the same underlying definition, and the provider DOES serialise them -- it takes a per-workflow mutex. That mutex is in-process only, so it protects components within a single apply and nothing else: two concurrent applies, or an apply overlapping a portal edit, silently lose one side's changes, because every write is a read-modify-write of the whole workflow.
  • A workflow whose definition is also managed inline by terraform-azurerm-logic-app-workflow will fight these resources. Choose one mechanism per workflow.
  • This resource type has no tags surface.

🔑 Required Azure RBAC Roles / Permissions

  • Logic App Contributor at the workflow's resource group, or a custom role covering Microsoft.Logic/workflows/write — a trigger is written by updating the parent workflow definition, so the permission is on the workflow, not on a child resource.
  • Where the trigger body produces a callback URL (a request-style trigger), reading it requires Microsoft.Logic/workflows/triggers/listCallbackUrl/action — a read-shaped permission that confers invoke rights, because the URL is the credential.

Azure Prerequisites

  • An existing Logic App (Consumption) workflow.
  • Any API connection the trigger body references already created and authorized — a connector's consent flow is interactive for many connectors and is not something Terraform performs.
  • The upstream service the trigger watches already existing and reachable.
  • The Microsoft.Logic resource provider registered on the target subscription.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription.

📁 Module Structure

terraform-azurerm-logic-app-trigger-custom/
├── providers.tf   # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf   # name, logic_app_id, body, timeouts tail
├── main.tf        # keystone azurerm_logic_app_trigger_custom.this; dynamic timeouts
├── outputs.tf     # id, name, logic_app_id, callback_url (sensitive)
├── README.md      # this document
├── SCOPE.md       # cross-module contract
├── LICENSE        # MIT
└── .gitignore     # canonical library ignore set

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "queue_trigger" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-trigger-custom.git?ref=v1.0.0"

  name         = "When_A_Message_Arrives"
  logic_app_id = module.order_workflow.id

  body = jsonencode({
    type = "ApiConnection"
    inputs = {
      host = {
        connection = {
          name = "@parameters('$connections')['servicebus']['connectionId']"
        }
      }
      method = "get"
      path   = "/@{encodeURIComponent('orders')}/messages/head"
    }
    recurrence = {
      frequency = "Minute"
      interval  = 1
    }
  })
}

ℹ️ The caller owns the provider, its authentication, and the mandatory features {} block. This module never declares them.


🔌 Cross-Module Contract

Consumes

Input Type Source module
logic_app_id string terraform-azurerm-logic-app-workflow (id)
body string (JSON) caller (jsonencode() or file())

Emits

Output Description Consumed by
id The Terraform-composed trigger ID (first) audit inventories
name Trigger name review tooling
logic_app_id The parent workflow composition wiring
callback_url Sensitive. Populated only for a request-style body; empty otherwise any caller that must invoke the workflow

📚 Example Library

The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:

variable "orders_topic_id" {
  description = "id of an existing orders topic that these examples reference but do not create."
  type        = string
}

variable "request_trigger_callback_url" {
  description = "callback url of an existing request trigger that these examples reference but do not create."
  type        = string
}

variable "workflow_id" {
  description = "id of an existing workflow that these examples reference but do not create."
  type        = string
}
1 · A Service Bus queue trigger
module "queue_trigger" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-trigger-custom.git?ref=v1.0.0"

  name         = "When_A_Message_Arrives"
  logic_app_id = var.workflow_id

  body = jsonencode({
    type = "ApiConnection"
    inputs = {
      host   = { connection = { name = "@parameters('$connections')['servicebus']['connectionId']" } }
      method = "get"
      path   = "/@{encodeURIComponent('orders')}/messages/head"
    }
    recurrence = { frequency = "Minute", interval = 1 }
  })
}

ℹ️ Note the connector trigger still carries its own recurrence — it is a poll, not a push. The polling interval is what determines how often the connector checks, and therefore part of the cost.

2 · Loading the body from a file
body = file("${path.module}/triggers/on-message.json")

💡 For anything beyond a few lines this is the better form: the trigger lives in a real .json file that an editor can validate and a reviewer can read as a diff.

3 · When to use a typed module instead
# ❌ Reaching for the escape hatch when a typed module exists.
body = jsonencode({
  type       = "Recurrence"
  recurrence = { frequency = "Day", interval = 1 }
})
# ✅ Typed: validated frequency enum, positive interval, range-checked schedule,
#    and the run rate emitted as an output.
module "trigger" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-trigger-recurrence.git?ref=v1.0.0"
  name         = "Every_Day_At_2am"
  logic_app_id = var.workflow_id
  frequency    = "Day"
  interval     = 1
}

💡 A schedule belongs in terraform-azurerm-logic-app-trigger-recurrence; an HTTP entry point belongs in -http-request. Use this module for what the provider does not model, not as a general-purpose trigger builder.

4 · An Event Grid trigger
body = jsonencode({
  type = "ApiConnectionWebhook"
  inputs = {
    host = { connection = { name = "@parameters('$connections')['azureeventgrid']['connectionId']" } }
    body = {
      properties = {
        topic       = var.orders_topic_id
        destination = { endpointType = "webhook" }
      }
    }
    path = "/subscriptions/@{encodeURIComponent('Microsoft.EventGrid.Topics')}/providers/@{encodeURIComponent('Microsoft.EventGrid.Topics')}/resource/eventSubscriptions"
  }
})

ℹ️ ApiConnectionWebhook is a push trigger — the connector registers a subscription rather than polling, so there is no recurrence. Terraform creates the trigger; the subscription itself is registered by the runtime on first activation.

5 · A sliding-window trigger
body = jsonencode({
  type = "SlidingWindow"
  recurrence = {
    frequency = "Hour"
    interval  = 1
  }
  inputs = {
    delay = "PT10M"
  }
})

💡 A sliding window guarantees each time period is processed exactly once, even after a failure — which a plain Recurrence does not. There is no typed module for it, which is a good example of why this escape hatch exists.

6 · A request-style body, and its callback URL
body = jsonencode({
  type = "Request"
  kind = "Http"
  inputs = {
    schema = {
      type     = "object"
      required = ["orderId"]
      properties = { orderId = { type = "string" } }
    }
  }
})
output "trigger_url" {
  value     = var.request_trigger_callback_url
  sensitive = true
}

⚠️ Here callback_url is populated, and it is a bearer credential. Prefer terraform-azurerm-logic-app-trigger-http-request for this shape — it gives a typed schema, a validated method, and the same sensitive output, with plan-time checks this module cannot offer.

7 · Why `callback_url` is usually empty
# A Service Bus poll trigger has no callback URL — the output is an empty string.
output "queue_trigger_url" {
  value     = module.queue_trigger.callback_url
  sensitive = true
}

ℹ️ An empty value is normal for any non-request trigger, not a failure. The output is marked sensitive unconditionally because the body is opaque — the module cannot tell in advance whether a given body produces a credential.

8 · Keeping secrets out of the body
# ❌ The body is stored in the workflow definition, readable by anyone with read
#    access to the Logic App.
body = jsonencode({
  type = "ApiConnection"
  inputs = {
    headers = { "x-api-key" = "abc123..." }
  }
})
# ✅ Reference a workflow parameter, or let the connection hold the credential.
body = jsonencode({
  type = "ApiConnection"
  inputs = {
    headers = { "x-api-key" = "@parameters('vendorApiKey')" }
  }
})

🔒 A connector's credential belongs in the API connection itself, which stores it outside the workflow definition. Where a raw header is unavoidable, source it from a workflow parameter backed by Key Vault.

9 · The connection-consent gap
module "queue_trigger" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-trigger-custom.git?ref=v1.0.0"

  name         = "When_A_Message_Arrives"
  logic_app_id = var.workflow_id
  body         = file("${path.module}/triggers/on-message.json")

  # Terraform can create the connection resource — it cannot authorize it.
  depends_on = [module.servicebus_connection]
}

⚠️ For many connectors the connection needs an interactive consent that Terraform cannot perform. A first apply then succeeds and the trigger never fires. Check the connection's status in the portal after the first deployment, and expect a manual authorization step in the runbook.

10 · A poll interval is a cost decision
body = jsonencode({
  type       = "ApiConnection"
  inputs     = { /* ... */ }
  recurrence = { frequency = "Minute", interval = 1 } # 1,440 polls a day
})
recurrence = { frequency = "Minute", interval = 5 } # 288 polls a day

⚠️ Each poll is a trigger execution, and Logic Apps bills per action execution. A one-minute poll on a quiet queue is mostly paying to find nothing. The typed recurrence module emits its rate as an output for this reason; here the rate is buried in the opaque body, so record it in a comment or a variable.

11 · Serialising the definition write
module "first_action" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-action-http.git?ref=v1.0.0"
  name         = "Fetch_Order"
  logic_app_id = var.workflow_id
  method       = "GET"
  uri          = "https://orders.example.com/api/orders/1"

  depends_on = [module.queue_trigger] # trigger first, then actions
}

💡 Triggers and actions rewrite the same workflow definition and the provider DOES serialise them -- it takes a per-workflow mutex. That mutex is in-process only, so it protects components within a single apply and nothing else: two concurrent applies, or an apply overlapping a portal edit, silently lose one side's changes, because every write is a read-modify-write of the whole workflow. A depends_on from each action onto the trigger both expresses the real ordering and avoids concurrent-update conflicts.

12 · Custom timeouts
timeouts = {
  create = "30m"
  read   = "5m"
  update = "30m"
  delete = "30m"
}

ℹ️ These govern Terraform's write of the trigger into the workflow definition, not the trigger's own polling behaviour — which lives inside the opaque body.

13 · Importing an existing trigger
terraform import 'module.queue_trigger.azurerm_logic_app_trigger_custom.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-integration/providers/Microsoft.Logic/workflows/wf-orders/triggers/When_A_Message_Arrives"

⚠️ That ID is composed by hand — the workflow's Resource ID with /triggers/<name> appended. No Azure API returns it.

14 · A workflow has one trigger
# ❌ Two trigger modules against the same workflow.
module "queue_trigger" { logic_app_id = var.workflow_id, /* ... */ }
module "timer_trigger" { logic_app_id = var.workflow_id, /* ... */ }

⚠️ Uniqueness across triggers is unchecked by the provider, and a Logic App runs one trigger. Where two entry points are genuinely needed, that is two workflows — often with a shared child workflow called from both.

15 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

# 1 · The resource group.
module "integration_rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-integration-eastus2"
  location = "eastus2"
}

# 2 · The upstream the trigger will watch.
module "orders_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-servicebus-namespace.git?ref=v1.0.0"

  name                = "sb-orders-eastus2"
  resource_group_name = module.integration_rg.name
  location            = module.integration_rg.location
  sku                 = "Standard"

  queues = {
    orders = {}
  }
}

# 3 · The workflow — definition left empty, because the trigger and actions own it.
module "order_workflow" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-workflow.git?ref=v1.0.0"

  name                = "wf-orders"
  resource_group_name = module.integration_rg.name
  location            = module.integration_rg.location
}

# 4 · The connector trigger — this module, because Service Bus has no typed trigger.
module "queue_trigger" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-trigger-custom.git?ref=v1.0.0"

  name         = "When_A_Message_Arrives"
  logic_app_id = module.order_workflow.id

  # Poll every 5 minutes, not every minute — each poll is a billed execution.
  body = jsonencode({
    type = "ApiConnection"
    inputs = {
      host   = { connection = { name = "@parameters('$connections')['servicebus']['connectionId']" } }
      method = "get"
      path   = "/@{encodeURIComponent('orders')}/messages/head"
    }
    recurrence = { frequency = "Minute", interval = 5 }
  })
}

# 5 · The work the trigger starts — typed, because HTTP has a typed module.
module "post_order" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-action-http.git?ref=v1.0.0"

  name         = "Post_Order"
  logic_app_id = module.order_workflow.id
  method       = "POST"
  uri          = "https://orders.example.com/api/orders"

  body = jsonencode({ payload = "@{triggerBody()}" })

  depends_on = [module.queue_trigger] # trigger first; serialises the definition write
}

# 6 · A budget, because the poll interval multiplies the action count.
module "integration_budget" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-consumption-budget-resource-group.git?ref=v1.0.0"

  time_period = { start_date = "2026-08-01T00:00:00Z" }

  name              = "integration-monthly"
  resource_group_id = module.integration_rg.id
  amount            = 500
  time_grain        = "Monthly"
}

💡 This wiring shows the division of labour: the Service Bus trigger needs this module because the provider has no typed resource for it, while the HTTP action uses the typed module and gets plan-time checks. Two things to carry away — the poll interval in step 4 is a cost decision buried in an opaque body, and the connector's API connection will need an interactive authorization that Terraform cannot perform. Output names on sibling modules are illustrative; match them to the versions you pin.


📥 Inputs

Required: name, logic_app_id, body.

Universal tail: timeouts. This resource type does not support tags.

Full object() schemas
variable "name" {
  type = string # force-new; must be unique across ALL triggers in the workflow (unchecked by the provider)
}

variable "logic_app_id" { type = string } # force-new

variable "body" {
  type = string # the raw Logic Apps trigger JSON; NOT validated by Terraform
}

variable "timeouts" {
  type    = object({ create = optional(string), read = optional(string), update = optional(string), delete = optional(string) })
  default = null
}

🧾 Outputs

Output Description Kind
id Terraform's composed ID for the trigger: the workflow's Resource ID with "/triggers/" appended Passthrough
name The trigger name, which is also its key under definition.triggers Passthrough
logic_app_id Resource ID of the parent Logic App workflow Passthrough
workflow_name The parent workflow's name, parsed out of logic_app_id Derived
resource_group_name Resource group holding the parent workflow, parsed out of logic_app_id Derived
subscription_id Subscription GUID parsed out of logic_app_id Derived
callback_url The trigger's invocation URL, populated by Azure only for a callback-style trigger and an empty string for every other type Passthrough
has_callback_url True when Azure returned a non-empty callback URL for this trigger Passthrough
callback_url_is_not_marked_sensitive_by_the_provider Constant true Constant
refreshing_this_resource_fetches_the_invocation_url Constant true: this module's read path is capable of fetching the invocation URL, and that is what makes plan access credential access Constant
trigger_type The trigger's declared type, matched case-insensitively against the list Microsoft publishes in its trigger-types reference, and emitted as the canonical spelling FROM THAT LITERAL LIST Derived
trigger_type_is_recognized True when the body's declared type matched Microsoft's published trigger-type list Derived
declared_type_is_a_callback_type True when the declared type is EXACTLY one of ApiConnectionWebhook, HTTPWebhook or Request -- the three-member list the provider tests the body's type against, with its ignore-case flag set to false, so the comparison is CASE-SENSITIVE Derived
declared_type_differs_from_a_callback_type_only_by_case True when the declared type would be a callback type if it were spelled the way the provider spells it, and is not Derived
top_level_keys_present Which of the documented top-level trigger elements this body declares, reported as the INTERSECTION of the body's keys with a literal list written in this file -- so the value can only ever contain strings from that list, never a key invented by the body Derived
body_byte_length Length of the body document in bytes Passthrough
declares_recurrence True when the body carries a recurrence block, which makes this a polling trigger driven by a schedule rather than a push trigger waiting on an endpoint Derived
declares_trigger_conditions True when the body declares a conditions array, which gates whether a run starts at all Derived
declares_split_on True when the body declares splitOn, which debatches an array returned by the trigger into ONE WORKFLOW RUN PER ARRAY ITEM Derived
declares_run_after_which_is_an_action_element True when the body declares runAfter -- reported, not rejected, because this module cannot prove Azure refuses it Derived
credential_indicators_present Map of literal substrings that commonly accompany an embedded credential, to whether each appears anywhere in the body, matched case-insensitively Derived
declares_inline_authentication True when the string "authentication" appears anywhere in the body, including inside a webhook's subscribe or unsubscribe object Passthrough
uses_managed_identity_authentication True when the body names ManagedServiceIdentity as an authentication type Passthrough
body_is_stored_in_state_in_plaintext Constant true Constant
reading_the_workflow_reveals_every_trigger_and_action_body Constant true Constant
trigger_is_not_an_azure_resource Constant true Constant
every_write_rewrites_the_entire_parent_workflow Constant true Constant
destroy_removes_one_key_and_rewrites_the_workflow Constant true Constant
destroy_sends_the_workflow_without_its_identity_block Constant true, and the sharpest edge in this module Constant
workflow_state_is_omitted_from_every_write Constant true Constant
concurrent_writes_are_serialized_only_inside_one_terraform_process Constant true, and the failure it describes is silent Constant
body_updates_in_place Constant true Constant
changing_name_or_logic_app_id_replaces_the_trigger Constant true Constant
trigger_names_are_not_checked_for_uniqueness Constant true Constant
read_strips_evaluated_recurrence_before_storing_the_body Constant true, and specific to triggers -- the action path has no equivalent Constant
body_diffs_are_suppressed_only_for_key_order_and_whitespace Constant true Constant
a_trigger_deleted_outside_terraform_is_recreated_silently Constant true Constant
this_module_ships_no_tags_variable Constant true Constant

callback_url is marked sensitive unconditionally, because the body is opaque and the module cannot tell whether a given trigger produces a credential. No other secret is accepted or emitted — secrets a trigger needs belong in a connection or a workflow parameter.

🧠 Architecture Notes

🔴 Destroying this trigger rewrites the parent workflow and DROPS its managed identity. There is no delete API for a single trigger, so the provider reads the whole workflow, removes the trigger, and PUTs it back. The component-update path carries Identity: read.Model.Identity; the component-REMOVE path builds the workflow without any Identity field, and both are full PUTs. The workflow therefore loses its managed identity -- and every role assignment granted to that identity -- as a side effect of deleting one trigger. Nothing in the plan shows this, because the workflow is not a resource this module manages. Verify the parent workflow's identity after any destroy that touches a trigger.

  • This module trades type safety for reach, and the reach is the point. Most real Logic App triggers are connector-based — Service Bus, Event Grid, SharePoint, Outlook — and none has a typed resource. Terraform checks that body is a string and nothing more, so a wrong type, a malformed expression, or a reference to a connection that does not exist all surface at apply or run time.
  • No validation is offered on purpose. A JSON-shape check would pass any well-formed document, including one entirely wrong for Logic Apps, and would imply an assurance the module cannot provide. Documenting what is unchecked is more honest than a check that catches nothing real.
  • callback_url is marked sensitive even though it is usually empty. Where a body is request-style, possession of the URL is sufficient to invoke the workflow, and the provider does not flag it. Marking it conditionally is not expressible — the body is opaque — so the module errs toward treating it as a credential. That matches the call this library made for automation-webhook's trigger URI.
  • The connection-consent gap is the most common operational surprise. Terraform can create an API connection resource but cannot complete a connector's interactive authorization. The result is a clean apply and a trigger that never fires, which reads as a Terraform problem and is not one. Expect a manual authorization step in the runbook.
  • A poll interval hides inside the body. A connector trigger is usually a poll with its own recurrence, and every poll is a billed execution. The typed recurrence module emits its rate as an output precisely so it is reviewable; here it is buried, so record it deliberately.
  • A workflow has one trigger. Uniqueness is unchecked by the provider, and two trigger modules against one workflow is almost always a mistake rather than a design — two entry points means two workflows.
  • Triggers and actions rewrite the same workflow definition. They are parts of one document, not independent children, and the provider DOES serialise them -- it takes a per-workflow mutex. That mutex is in-process only, so it protects components within a single apply and nothing else: two concurrent applies, or an apply overlapping a portal edit, silently lose one side's changes, because every write is a read-modify-write of the whole workflow — hence depends_on from actions onto the trigger.
  • This mechanism and an inline definition are mutually exclusive. If the workflow module also carries a definition, the two fight on every apply.
  • Secrets do not belong in the body. It is stored in the workflow definition and readable by anyone with read access to the Logic App. Use the API connection, or a workflow parameter sourced from Key Vault.
  • features {} dependence. The module carries no provider {} block. If it appears not to initialize in isolation, the cause is a missing caller-side provider "azurerm" { features {} }.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Callback URL handling emitted sensitive unconditionally, though usually empty — (no opt-out)
Typed alternative documentation steers to -recurrence / -http-request use this module for a schedule or HTTP trigger anyway
Secrets in the definition steered to an API connection or workflow parameter inline a credential in body
Body correctness documented as unchecked, rather than implied safe — (no validation is possible)
Definition ownership documented as one mechanism per workflow mix with an inline workflow definition
Reviewability file() / jsonencode() steered over heredocs inline heredoc

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the module with ?ref=v1.0.0 — never a branch.
  • This library is plan-only during authoring; a human runs terraform plan / apply from CI against real credentials.
  • After the first apply, check the API connection's authorization status in the portal. A clean apply does not mean the trigger will fire.

🧪 Testing

  • terraform validate proves the configuration is internally consistent and type-correct against the pinned provider schema. For this module that is a narrow claim: it confirms three strings are present, not that the trigger is valid.
  • terraform fmt -check enforces canonical formatting.
  • Neither command calls Azure. Only terraform plan (run by a human, from CI) exercises the ARM API — the module ships without any cloud apply. Whether the body is a valid trigger, whether a referenced connection exists and is authorized, and whether the upstream service is reachable are all apply-time or run-time facts.

💬 Example Output

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

id           = "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-integration-eastus2/providers/Microsoft.Logic/workflows/wf-orders/triggers/When_A_Message_Arrives"
name         = "When_A_Message_Arrives"
logic_app_id = "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-integration-eastus2/providers/Microsoft.Logic/workflows/wf-orders"
callback_url = <sensitive>

🔍 Troubleshooting

Symptom Cause Fix
Provider configuration not present / features error No caller-side provider "azurerm" { features {} }. Add the provider block with features {} in the root module.
Apply succeeds but the trigger never fires The API connection was never authorized — Terraform cannot complete an interactive consent. Authorize the connection in the portal, then re-enable the workflow.
Apply fails with a workflow-update conflict Trigger and action resources wrote the same definition concurrently. Add depends_on from the actions onto the trigger.
The trigger disappeared after an apply The workflow module also carries an inline definition. Pick one mechanism per workflow.
Run fails: connection not found The body references a connection name that does not exist in $connections. Create the connection and match the parameter name exactly.
callback_url is empty Normal for any non-request trigger type. Not a failure. Use -http-request if you need an HTTP entry point.
Output refers to sensitive values A root output exposes callback_url without sensitive = true. Mark the output sensitive — the upstream value is already sensitive.
Unexpectedly high Logic Apps cost A poll recurrence inside the body fires more often than expected. Widen the poll interval; each poll is a billed execution.
A second trigger caused unexpected behaviour A Logic App has one trigger; uniqueness is unchecked. Split into two workflows, sharing a child workflow if needed.
Plan wants to replace the trigger after a rename name is force-new. Expected; the trigger is recreated in place of the old one.
terraform import cannot find the trigger The ID is Terraform-composed and no Azure API returns it. Compose it: workflow Resource ID + /triggers/<name>.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."