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.
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.jsonin the integration's root, read by folder 2's pipeline andjsondecoded 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
terraformVersionvariable 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.