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. Targetshashicorp/azurerm ~> 4.0.
- 🚪 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_urlassensitiveeven 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 —StringIsJSONruns at plan — and validates nothing about its meaning.- 🧭 Points at
terraform-azurerm-logic-app-trigger-recurrenceand-http-requestwhere 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.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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;
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;
Resource inventory
| Resource | Count | Role |
|---|---|---|
azurerm_logic_app_trigger_custom.this |
1 | The keystone trigger, with its timeouts block. |
| 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):
nameandlogic_app_idare force-new.namemust 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
idis unique to Terraform: workflow ID +/triggers/<name>, matching no identifier the Azure APIs return. - Terraform checks only that
bodyis well-formed JSON —StringIsJSONruns at plan — and validates nothing about its meaning. A malformed trigger, a wrongtype, or a reference to a non-existent connection all fail at apply or at run time, never at plan. callback_urlis 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-workflowwill fight these resources. Choose one mechanism per workflow. - This resource type has no
tagssurface.
Logic App Contributorat the workflow's resource group, or a custom role coveringMicrosoft.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.
- 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.Logicresource provider registered on the target subscription. - The caller configures the
provider "azurerm" { features {} }block, auth, and subscription.
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
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.
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 |
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
.jsonfile 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"
}
})ℹ️
ApiConnectionWebhookis a push trigger — the connector registers a subscription rather than polling, so there is norecurrence. 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
Recurrencedoes 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
}
⚠️ Herecallback_urlis populated, and it is a bearer credential. Preferterraform-azurerm-logic-app-trigger-http-requestfor this shape — it gives a typedschema, a validatedmethod, 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_onfrom 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.
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
}| 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_urlis 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.
🔴 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
bodyis a string and nothing more, so a wrongtype, 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_urlis 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 forautomation-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_onfrom 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 noprovider {}block. If it appears not to initialize in isolation, the cause is a missing caller-sideprovider "azurerm" { features {} }.
| 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 |
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/applyfrom 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.
terraform validateproves 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 -checkenforces 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.
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>
| 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>. |
- azurerm provider —
azurerm_logic_app_trigger_custom - Logic Apps workflow definition language — triggers
- Managed connectors in Azure Logic Apps
- Sibling modules:
terraform-azurerm-logic-app-workflow,terraform-azurerm-logic-app-trigger-recurrence,terraform-azurerm-logic-app-trigger-http-request,terraform-azurerm-logic-app-action-http,terraform-azurerm-logic-app-action-custom. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."