Skip to content
Kumar Chandrachooda
Azure & DevOps

Logic Apps, Four Folders at a Time

Each Logic App in this Azure estate was split across four sibling stacks - runtime, workflows, APIM registration and service discovery - a clean lifecycle separation that quadrupled the folder, pipeline and state count per integration. Part 8 of the Terraform on Azure series.

By Kumar Chandrachooda 29 Dec 2025 5 min read
One integration, four lifecycles

Logic Apps occupy an awkward seat in infrastructure-as-code. The runtime — the plan, the storage account, the app settings — is clearly infrastructure and clearly Terraform's job. The workflows — the JSON definitions that actually do the work — change weekly, ship like application code, and suffer badly when Terraform owns them: every workflow tweak becomes an infrastructure apply, every infrastructure change risks clobbering a workflow someone edited in the designer.

The estate in this series (Part 1 has the setup) answered this with a pattern I have not seen written up anywhere: every Logic App is four sibling folders, each with its own pipeline, its own lifecycle, and — where Terraform is involved — its own state. It is the cleanest separation of concerns in the whole estate, and also the most expensive. Both halves deserve the write-up.

The four folders

For a hypothetical integration called claims-intake, the estate would contain:

la-claims-intake                        # 1. the runtime (Terraform)
la-claims-intake-workflows              # 2. the workflow code (zip deploy)
la-claims-intake-apim-setup             # 3. gateway registration (Terraform)
la-claims-intake-servicediscovery-setup # 4. registry records (Terraform)

Folder 1 — the runtime. A standard five-file Terraform stack, exactly the Part 1 shape, provisioning a Logic App Standard: the App Service Plan (workflow tier), the backing storage account, app settings, VNet integration, identities. It knows nothing about what workflows will run there. It changes rarely — SKU bumps, settings, network moves.

Folder 2 — the workflows. Not Terraform at all. This folder holds the artefacts a workflow developer actually produces — workflow.json per workflow, connections.json, host.json — and a pipeline that zip-deploys them onto the runtime folder 1 created:

steps:
  - task: ArchiveFiles@2
    inputs:
      rootFolderOrFile: $(Build.SourcesDirectory)/workflows
      archiveFile: $(Build.ArtifactStagingDirectory)/workflows.zip
  - task: AzureFunctionApp@1        # Logic App Standard rides the Functions host
    inputs:
      appType: workflowapp
      appName: la-claims-intake-$(env)
      package: $(Build.ArtifactStagingDirectory)/workflows.zip

That appType: workflowapp line is the tell — Logic App Standard is a Functions host under the hood, so workflow code deploys like any app. Workflow changes ship on the application cadence: no plan, no state, no infrastructure review. This is the folder that changes weekly.

Folder 3 — APIM registration. Back to Terraform. Each workflow in a Logic App exposes a trigger URL signed with a SAS token — possession of the URL is authorization, which is not a story you want callers depending on. This stack fetches each workflow's callback URL (via an external data source shelling out to az rest .../listCallbackUrl — azurerm has no resource for it), splits off the SAS query, stores it as a secret APIM named value, and creates an APIM backend per workflow, for_each over the workflow list:

locals {
  workflows = ["intake", "validate", "route"]
}

data "external" "callback" {
  for_each = toset(local.workflows)
  program  = ["pwsh", "-File", "${path.module}/get-callback-url.ps1",
              var.logic_app_name, each.key]
}

resource "azurerm_api_management_backend" "wf" {
  provider = azurerm.apim
  for_each = toset(local.workflows)
  name     = "${var.service_name}-${each.key}"
  protocol = "http"
  url      = data.external.callback[each.key].result.base_url
  # SAS query lives in a secret named value; policy appends it server-side
}

Callers now hit APIM with ordinary subscription keys; the SAS never leaves the gateway. (The estate ate significant complexity to keep raw SAS URLs out of consumers' hands — the right call.)

Folder 4 — service discovery. The smallest of the four: one Key Vault record per workflow — {"URL": ..., "key": ..., "type": "azlogicapp"} — the poor man's service registry from Part 6, letting internal consumers resolve workflows without touching APIM.

Why this is genuinely good

The separation earns its keep in three specific ways.

Change frequency isolation. Workflow deploys (weekly) never risk the runtime; runtime changes (quarterly) never redeploy workflow code. In the coupled alternative — workflow JSON inlined into Terraform via azapi or template resources — every workflow edit is an infrastructure apply reviewed by infrastructure people, and terraform plan becomes the bottleneck on business logic. I have seen that version elsewhere; it is worse.

Failure isolation. A bad workflow deploy is rolled back by redeploying a zip — seconds, no state involved. A bad infrastructure change is confined to folders 1, 3 or 4's small state.

Team isolation. Integration developers own folder 2 and need no Terraform knowledge. Platform-minded folk own 1, 3, 4. The folder boundary is the skills boundary.

What it costs

Now the ledger's other side, and it is not small. Per integration: four folders, four pipelines, four pipeline-variable sets, three state files — and the estate had many integrations. Multiply and you understand where the thousand-state-file figure from Part 3 comes from.

The subtler cost is drift surface. Four siblings that must agree — on the Logic App's name, the workflow list, the environment set — with nothing enforcing agreement except the naming convention from Part 7. The estate's siblings showed exactly the decay you would predict:

  • One folder's locals file named local.tf, its siblings' locals.tf — harmless to Terraform, poison to tooling and grep.
  • Siblings pinned to different Terraform versions, so the “same” integration planned under different language semantics depending on which quarter of it you touched.
  • Add a workflow and three folders need edits (2, 3, 4); forget one and you get a workflow that runs but is invisible to the gateway or the registry. Nothing fails loudly. It is the rename problem again, at higher frequency.

There is also an ordering dependency the decoupling merely hides: folder 3 cannot register a workflow until folder 2 has deployed it (the callback URL does not exist), and folder 2 needs folder 1's runtime. The apply order is 1 → 2 → 3 → 4, enforced by nothing but operator knowledge.

The verdict, and what I'd change

Keep the lifecycle boundaries — they are correct. Runtime, code, and exposure genuinely change at different speeds for different reasons, and the estate's boundaries cut exactly along those lines. This is the rare case where “more moving parts” reflects real structure rather than accidental complexity.

But I would collapse the repetition:

  • Merge folders 3 and 4. APIM registration and the discovery record share their inputs (workflow list, names, environment) and their cadence; two stacks means two states to drift. One “exposure” stack halves the sibling count.
  • Single source the workflow list. A workflows.json in the integration's root, read by folder 2's pipeline and jsondecoded by the exposure stack — add a workflow in one place, every layer follows. This one file would have prevented most of the drift I saw.
  • Pin versions at the integration level, not per folder — one terraformVersion variable shared by the siblings.
  • Document the 1→2→3→4 ordering in each folder's README, because the person applying folder 3 at 2 a.m. is not the person who knew.

Folder 3's PowerShell-shaped machinery — external data sources, az rest, SAS string surgery — is a specimen of a bigger theme: what happens when the provider cannot do what the platform needs. That theme gets its own article next: Part 9, Terraform's Escape Hatches Are Load-Bearing.