Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Logic App Custom Action Terraform Module

Adds a single action to an existing Logic App workflow, defined as a raw JSON body (azurerm_logic_app_action_custom). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources


🧩 Overview

  • 🧩 Adds one action to an existing Logic App workflow as a keystone resource named this.
  • 🚪 The escape hatch for any action the provider has no typed resource for — a connector call, a condition, a scope, a loop.
  • 📄 Takes the raw Logic Apps action body as JSON, so anything the definition language can express is available.
  • 🔗 Emits name so other actions can order against it via run_after without hard-coding a string.
  • ⚠️ States plainly what is not checked: 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-action-http where a typed alternative exists.

💡 Why it matters: This module trades type safety for reach. That is the right trade for a connector action the provider does not model — and the wrong one for an HTTP call, which has a typed module with real enum checking. The documentation's job here is to make sure you know which situation you are in.

❤️ 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"]
  trig["terraform-azurerm-logic-app-trigger-recurrence or -http-request or -custom"]
  this["terraform-azurerm-logic-app-action-custom"]
  act["azurerm_logic_app_action_custom"]
  http["terraform-azurerm-logic-app-action-http"]
  ia["terraform-azurerm-logic-app-integration-account"]

  wf -->|"logic_app_id"| this
  wf -->|"logic_app_id"| trig
  wf -->|"logic_app_id"| http
  trig -->|"starts the run"| act
  this -->|"creates"| act
  act -->|"name referenced by run_after"| http
  ia -->|"maps and schemas the body may reference"| 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 act keystone;
  class wf,trig,http,ia sib;
Loading

🧬 What this module builds

flowchart TB
  name["name: unique across all actions, force-new"]
  wf["logic_app_id: force-new"]
  body["body: opaque JSON action definition"]
  guard["no plan-time validation of the document"]
  this["terraform-azurerm-logic-app-action-custom"]
  act["azurerm_logic_app_action_custom.this"]
  order["other actions order against this name via run_after"]
  out["outputs: id, name, logic_app_id"]

  name -->|"identity and ordering handle"| this
  wf -->|"parent workflow"| this
  body -->|"the action itself"| this
  guard -->|"prefer a typed action module"| body
  this -->|"creates"| act
  act -->|"referenced by"| order
  act -->|"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 act keystone;
  class name,wf,body,guard,order,out sib;
Loading

Resource inventory

Resource Count Role
azurerm_logic_app_action_custom.this 1 The keystone action, 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 action in the workflow, and the provider cannot check that — each action is a separate resource, so Terraform sees only this one.
  • The id is unique to Terraform: it is the workflow ID with /actions/<name> appended and matches no identifier the Azure APIs return for the action. Importing means composing it by hand.
  • Terraform validates the body only as JSON at plan (StringIsJSON), and that check has two gaps: an EMPTY string and any non-object JSON both pass plan and fail at apply. A malformed action, a wrong type, or a reference to a non-existent connection all fail at apply or at run time, never at plan.
  • Actions and triggers 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 with many action resources can produce concurrent-update conflicts on apply.
  • 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 — an action is written by updating the parent workflow definition, so the permission is on the workflow, not on a child resource.
  • Note the consequence: anyone who can add an action can change what the workflow does using whatever identity and connections it already holds. Treat write access to a workflow as equivalent to that workflow's effective permissions.

Azure Prerequisites

  • An existing Logic App (Consumption) workflow.
  • Any API connection the action body references already created and authorized — a connector's consent flow is interactive for many connectors and is not something Terraform performs.
  • 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-action-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_action_custom.this; dynamic timeouts
├── outputs.tf     # id, name, logic_app_id
├── README.md      # this document
├── SCOPE.md       # cross-module contract
├── LICENSE        # MIT
└── .gitignore     # canonical library ignore set

⚙️ Quick Start

provider "azurerm" {
  features {}
}

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

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

  body = jsonencode({
    type = "Compose"
    inputs = {
      subject = "Order received"
      body    = "@{triggerBody()}"
    }
  })
}

ℹ️ 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 action ID (first) audit inventories
name Action name terraform-azurerm-logic-app-action-http (run_after[*].action_name), and any other action ordering against this one
logic_app_id The parent workflow composition wiring

📚 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 "get_order_name" {
  description = "name of an existing get order 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 minimal Compose action
module "compose" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-action-custom.git?ref=v1.0.0"

  name         = "Compose_Message"
  logic_app_id = var.workflow_id

  body = jsonencode({
    type   = "Compose"
    inputs = "@{triggerBody()?['orderId']}"
  })
}

💡 jsonencode({...}) rather than a heredoc means a malformed document fails at plan, and the HCL stays readable.

2 · Loading the body from a file
body = file("${path.module}/actions/enrich-order.json")

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

3 · Ordering with `runAfter` inside the body
body = jsonencode({
  type = "Compose"
  inputs = "@{body('Get_Order')}"
  runAfter = {
    Get_Order = ["Succeeded"]
  }
})

ℹ️ A custom action expresses ordering inside its own runAfter map, because this resource has no typed run_after block — unlike terraform-azurerm-logic-app-action-http. The key is the preceding action's name.

4 · Wiring the predecessor name instead of typing it
body = jsonencode({
  type   = "Compose"
  inputs = "@{body('${var.get_order_name}')}"
  runAfter = {
    (var.get_order_name) = ["Succeeded"]
  }
})

💡 Referencing the upstream module's name output means a rename surfaces as a plan diff rather than as a workflow that fails at run time. Note the parenthesised key — HCL needs it for a computed map key.

5 · A condition (If) action
body = jsonencode({
  type = "If"
  expression = {
    and = [{
      greater = ["@int(triggerBody()?['amount'])", 1000]
    }]
  }
  actions     = {}
  else        = { actions = {} }
  runAfter    = {}
})

ℹ️ Control-flow actions — If, Switch, Foreach, Until, Scope — have no typed resources at all, which is the main reason this module exists.

6 · A Foreach loop with a nested action
body = jsonencode({
  type     = "Foreach"
  foreach  = "@triggerBody()?['lines']"
  actions = {
    Compose_Line = {
      type   = "Compose"
      inputs = "@{items('Foreach_Line')?['sku']}"
    }
  }
  runAfter = {}
})

⚠️ Actions nested inside a Foreach or Scope are part of this action's body, not separate resources. Do not also create them with their own module instances — they would be duplicated.

7 · A connector action referencing an API connection
body = jsonencode({
  type = "ApiConnection"
  inputs = {
    host = {
      connection = {
        name = "@parameters('$connections')['office365']['connectionId']"
      }
    }
    method = "post"
    path   = "/v2/Mail"
    body = {
      To      = "ops@example.com"
      Subject = "Order received"
    }
  }
  runAfter = {}
})

⚠️ The connection must already exist and be authorized. Many connectors require an interactive consent flow that Terraform cannot perform, so a first apply can produce an action that exists and fails when it runs.

8 · Error handling: an action that runs only on failure
body = jsonencode({
  type   = "Compose"
  inputs = "Order processing failed: @{result('Process_Order')}"
  runAfter = {
    Process_Order = ["Failed", "TimedOut"]
  }
})

💡 This is a Logic App's catch block. Listing several results means "any of these", unlike the typed HTTP action's run_after block, where several predecessors must all reach their stated result.

9 · 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 = "Http"
  inputs = {
    headers = { Authorization = "Bearer eyJhbGciOi..." }
  }
})
# ✅ Reference a workflow parameter, or use a connection that holds the credential.
body = jsonencode({
  type = "Http"
  inputs = {
    headers = { Authorization = "@parameters('apiToken')" }
  }
})

🔒 The action body is configuration, not a secret store. Put the value in a workflow parameter sourced from Key Vault, or use an API connection — and note that a typed HTTP action (terraform-azurerm-logic-app-action-http) documents this same constraint with a real schema behind it.

10 · Several actions from a keyed map
locals {
  actions = {
    Compose_Header = { type = "Compose", inputs = "@{triggerBody()?['header']}" }
    Compose_Lines  = { type = "Compose", inputs = "@{triggerBody()?['lines']}" }
  }
}

module "actions" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-action-custom.git?ref=v1.0.0"
  for_each = local.actions

  name         = each.key
  logic_app_id = var.workflow_id
  body         = jsonencode(each.value)
}

⚠️ Every one of these updates 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. On a workflow with many actions this can produce concurrent-update conflicts; see example 11.

11 · Serialising updates to avoid conflicts
module "second_action" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-action-custom.git?ref=v1.0.0"

  name         = "Compose_Second"
  logic_app_id = var.workflow_id
  body         = jsonencode({ type = "Compose", inputs = "second" })

  # Forces Terraform to write one action at a time.
  depends_on = [module.first_action]
}

💡 A depends_on chain between action modules is the practical fix for concurrent-update errors on a busy workflow. Ordering the actions by their run_after dependencies usually produces the chain naturally.

12 · When to use the typed HTTP action instead
# ❌ Reaching for the escape hatch when a typed module exists.
module "call_api" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-action-custom.git?ref=v1.0.0"

  logic_app_id                 = var.logic_app_id
  name   = "Call_API"
  body   = jsonencode({ type = "Http", inputs = { method = "POST", uri = "http://api.example.com" } })
}
# ✅ Typed: validated method enum, HTTPS-enforced URI, typed run_after.
module "call_api" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-action-http.git?ref=v1.0.0"

  name         = "Call_API"
  logic_app_id = var.workflow_id
  method       = "POST"
  uri          = "https://api.example.com/orders"
}

💡 The typed module would have rejected that http:// URI at plan. Use this module for what the provider does not model, not as a general-purpose action builder.

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

ℹ️ The defaults are ample. Raise create only if workflow updates in your tenant are consistently slow — which on a workflow with many action resources is usually the concurrency issue from example 11 rather than latency.

14 · Importing an existing action
terraform import 'module.compose.azurerm_logic_app_action_custom.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-integration/providers/Microsoft.Logic/workflows/wf-orders/actions/Compose_Message"

⚠️ That ID is composed by hand — it is the workflow's Resource ID with /actions/<name> appended, and no Azure API returns it. Get the workflow ID right and append the action name exactly as it appears in the definition.

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 workflow. Its definition is left empty here, because the trigger and
#     actions below own it — do not also define them inline.
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
}

# 3 · A request trigger. Its callback URL is a bearer credential — see that module.
module "order_trigger" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-trigger-http-request.git?ref=v1.0.0"

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

  schema = jsonencode({
    type     = "object"
    required = ["orderId", "amount"]
    properties = {
      orderId = { type = "string" }
      amount  = { type = "number" }
    }
  })
}

# 4 · A typed HTTP action — the first step, so no run_after.
module "fetch_customer" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-action-http.git?ref=v1.0.0"

  name         = "Fetch_Customer"
  logic_app_id = module.order_workflow.id
  method       = "GET"
  uri          = "https://crm.example.com/api/customers/@{triggerBody()?['orderId']}"

  depends_on = [module.order_trigger]
}

# 5 · A control-flow action — this module, because If has no typed resource.
module "high_value_check" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-action-custom.git?ref=v1.0.0"

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

  body = jsonencode({
    type = "If"
    expression = {
      and = [{ greater = ["@int(triggerBody()?['amount'])", 10000] }]
    }
    actions = {
      Compose_Escalation = {
        type   = "Compose"
        inputs = "Escalating order @{triggerBody()?['orderId']} for @{body('${module.fetch_customer.name}')?['name']}"
      }
    }
    else = { actions = {} }
    runAfter = {
      (module.fetch_customer.name) = ["Succeeded"]
    }
  })

  # Serialise definition writes; also expresses the real ordering.
  depends_on = [module.fetch_customer]
}

# 6 · A catch block — also this module, since it is a Compose with a Failed runAfter.
module "on_failure" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-action-custom.git?ref=v1.0.0"

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

  body = jsonencode({
    type   = "Compose"
    inputs = "Order @{triggerBody()?['orderId']} failed: @{result('${module.fetch_customer.name}')}"
    runAfter = {
      (module.fetch_customer.name) = ["Failed", "TimedOut"]
    }
  })

  depends_on = [module.high_value_check]
}

💡 This wiring shows the division of labour: resource group → workflow → trigger → a typed action where one exists → this module for the control-flow and catch actions the provider does not model. Two things to carry away — the depends_on chain serialises writes to the one shared workflow definition, and every runAfter key is wired from an upstream module's name so a rename cannot silently break the graph. 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 actions in the workflow (unchecked by the provider)
}

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

variable "body" {
  type = string # the raw Logic Apps action 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 action: the workflow's Resource ID with "/actions/" appended Passthrough
name The action name, which is also its key under definition.actions and the string sibling actions must use in their own runAfter maps 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
action_type The action's declared type, matched case-insensitively against a list of types evidenced in Microsoft's Workflow Definition Language reference and in the provider's own test suite, and emitted as the canonical spelling FROM THAT LITERAL LIST Derived
action_type_is_recognized True when the body's declared type matched the evidenced list above Derived
top_level_keys_present Which of the documented top-level action 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_run_after True when the body carries a runAfter map with at least one entry, meaning this action waits for a named sibling Derived
run_after_dependency_count How many sibling actions this action waits on, counted from the keys of runAfter Derived
runs_immediately_after_the_trigger True when runAfter is absent or empty, so this action starts as soon as the workflow triggers Derived
run_after_statuses_used Which predecessor statuses this action's runAfter map reacts to, reported as the intersection of the statuses found in the body with a literal list written in this file 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 nested action of a Scope, If or Foreach 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_action_body Constant true Constant
action_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_enabled_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_action Constant true Constant
action_names_are_not_checked_for_uniqueness Constant true Constant
run_after_targets_are_resolved_only_by_azure_at_apply Constant true Constant
body_diffs_are_suppressed_only_for_key_order_and_whitespace Constant true Constant
this_module_ships_no_tags_variable Constant true Constant

No secret is accepted or emitted. The action body is configuration; secrets it needs belong in a connection or a workflow parameter.

🧠 Architecture Notes

🔴 Destroying an action rewrites the parent workflow and DROPS its managed identity. There is no delete API for a custom action, so the provider reads the whole workflow, removes the action, and PUTs it back. The update path carries Identity: read.Model.Identity; the remove path builds the workflow without any Identity field. Because this is a full PUT, the workflow loses its managed identity -- and every role assignment granted to that identity -- as a side effect of deleting one action. Neither path sends the workflow's State either. Verify the parent workflow's identity after any destroy that touches an action.

  • This module trades type safety for reach, deliberately. Terraform checks that body is a string and nothing more. A wrong type, a malformed expression, or a reference to a connection that does not exist all surface at apply or at run time. That is acceptable for an action the provider does not model — and needless for an HTTP call, where terraform-azurerm-logic-app-action-http gives a validated method enum and an HTTPS-enforced URI.
  • No validation is offered on purpose. A JSON-shape check would pass any well-formed document, including one that is 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.
  • Every action resource rewrites the same workflow definition. Actions and triggers are not independent child resources in the API — they are parts of one document. 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, so a workflow with many action modules can fail with update conflicts. A depends_on chain fixes it, and ordering by run_after dependencies usually produces that chain anyway.
  • This mechanism and an inline definition are mutually exclusive. If terraform-azurerm-logic-app-workflow also carries a definition, the two fight on every apply. Pick one owner per workflow.
  • name is an identifier, not a label. Other actions reference it in their ordering, and it is part of the Terraform-composed id. Renaming replaces the action and orphans anything pointing at the old name, so the module emits name for wiring.
  • Nested actions are not separate resources. Actions inside a Foreach, Scope, If, or Until live in that action's own body. Creating them again as their own modules duplicates them.
  • 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 a workflow parameter sourced from Key Vault, or an API connection.
  • 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)
Typed alternative documentation steers to logic-app-action-http for HTTP calls use this module for an HTTP action anyway
Secrets in the definition steered to a workflow parameter or connection 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.
  • Because body is unchecked, a clean plan says little about whether the action works. Exercise the workflow after apply.

🧪 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 action 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 action, whether a referenced connection exists and is authorized, and whether a runAfter names a real action 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/actions/If_High_Value"
name         = "If_High_Value"
logic_app_id = "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-integration-eastus2/providers/Microsoft.Logic/workflows/wf-orders"

🔍 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 fails with a workflow-update conflict Several action or trigger resources wrote the same workflow definition concurrently. Chain them with depends_on, ideally in run_after order.
Actions disappear after an apply The workflow module also carries an inline definition, which overwrote them. Pick one mechanism per workflow — inline definition or separate action resources.
Apply succeeds, the workflow fails at run time body is not validated by Terraform. Check the run history for the actual error; a wrong type or expression is the usual cause.
Run fails: connection not found or unauthorized The API connection does not exist, or its interactive consent was never completed. Create and authorize the connection first; Terraform cannot complete a connector's consent flow.
A runAfter reference does nothing It names an action that does not exist, or the name was renamed. Wire the key from the upstream module's name output instead of typing it.
Duplicate actions in the workflow Nested actions were also created as their own module instances. Nested actions belong in the parent action's body only.
Plan wants to replace the action after a rename name is force-new. Expected; update anything referencing the old name in the same apply.
terraform import cannot find the action The ID is Terraform-composed and no Azure API returns it. Compose it: workflow Resource ID + /actions/<name>.

🔗 Related Docs


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