If your Logic Apps are edited in the Azure portal (and let's be honest, most are), production probably lives only in the portal. That means:
- No history: when a workflow breaks after an edit, you can't see what it looked like yesterday.
- No undo for deletes: a deleted Logic App is gone, definition and all.
- No source of truth: your repo, if you have one, drifts from what's actually running.
You don't need a big infrastructure-as-code migration to fix this. You need a daily, automatic snapshot of what's really running. Here's how I set one up in an afternoon.
The setup in one line
A scheduled pipeline reads Azure and commits to a locked backup branch. A second pipeline can restore any version from that branch. Both run from a separate, unprotected tooling branch.
What gets backed up
Everything is saved exactly as Azure returns it, one folder per resource, so a Git diff shows exactly what someone changed in the portal.
| Resource | Repo path | Exported via |
|---|---|---|
| Consumption Logic Apps | LogicApps/<name>/workflow.json |
ARM REST API (one GET per workflow) |
| API connections | ApiConnections/<name>/connection.json |
ARM REST API |
| API Management | APIM/<instance>/ |
Microsoft's open-source APIOps extractor |
Two details matter:
- Fetch each resource individually. List calls can omit parts of the definition.
- Delete folders for resources that no longer exist. The latest commit mirrors Azure, while Git history keeps every older version.
Name clashes across resource groups get a __<resource-group> suffix so nothing is overwritten.
The daily pipeline
It runs on a Microsoft-hosted agent, so nobody's laptop needs to be on. Each run does four things:
- Clone the backup branch.
- Sign in with a Reader service connection and export everything.
- Stage only the backup folders.
- Commit and push, but only if something changed.
schedules:
- cron: '30 1 * * *' # daily, 01:30 UTC
branches:
include: [backup-tooling]
always: true # run even if the repo hasn't changed
jobs:
- job: Backup
steps:
- checkout: self # tooling branch: scripts only
- script: git -c http.extraheader="AUTHORIZATION: bearer $SYSTEM_ACCESSTOKEN" clone --branch backup --single-branch "$(Build.Repository.Uri)" "$(backupDir)"
env: { SYSTEM_ACCESSTOKEN: $(System.AccessToken) }
- task: AzureCLI@2
inputs:
azureSubscription: my-reader-service-connection
scriptType: bash
scriptLocation: inlineScript
inlineScript: python3 sync/export.py --subscription "$(subscription)" --out "$(backupDir)"
- script: |
cd "$(backupDir)"
git add -A LogicApps ApiConnections APIM
git diff --cached --quiet && { echo "No changes"; exit 0; }
git commit -m "Daily Azure backup $(date -u +%F) [skip ci]"
git push origin HEAD:backup
Two easy-to-miss settings:
-
always: truemakes the schedule fire even with no new commits. -
[skip ci]stops the backup commit from triggering other pipelines.
Making the backup tamper-proof
A backup anyone can delete isn't a backup.
Don't use a branch lock. A lock blocks everyone except the person who set it, including the pipeline's build identity, and "Bypass policies" doesn't override it.
Use branch security instead (Repos → Branches → ⋯ → Branch security):
| Who | Contribute | Force push |
|---|---|---|
| Contributors | Deny | Deny |
| Project Administrators | Deny | Deny |
| Project Build Service | Allow | Deny |
In Azure DevOps, Deny beats Allow, and deleting a branch requires force-push. So no one, not even a project admin, can delete the branch or rewrite history, and only the pipeline can add commits.
Keep scripts on a separate branch. Once the backup branch is locked, you can't push script fixes to it either. An unprotected tooling branch solves that.
Restoring, safe by default
A second, manually triggered pipeline restores Logic Apps and connections from any backup version. The defaults are designed so a mis-click can't hurt production:
| Parameter | Default |
|---|---|
| Logic Apps |
ALL (or a comma-separated list) |
| Backup version |
latest (or any commit ID) |
| Target | Original subscription/resource group (or a new one) |
| State after restore | Disabled |
| Create missing connections | Yes (existing ones never overwritten) |
| Dry run | On |
Under the hood it PUTs each workflow to the ARM REST API, rewrites resource IDs if the target differs, and creates the resource group if needed. At the end it prints a to-do list: connections to re-authorize, identities to re-grant, and trigger URLs that changed.
Tip: to check a restore you can't rehearse, wrap the same request bodies in an ARM template and run
az deployment group validate. Azure checks everything and deploys nothing.
What a backup can't give you
This protects your definitions, not your whole environment:
| Gap | What to do after restore |
|---|---|
| Connection passwords / OAuth sign-ins | Re-authorize connections (Office 365, Teams, SharePoint…) |
| HTTP trigger URLs | Re-created apps get new URLs, so update callers (overwriting in place keeps the URL) |
| Managed identity access | Re-grant roles on Key Vault, Service Bus, etc. |
| Run history | Not exportable. Accept the loss |
| Secrets in definitions | Exported as-is. Keep the repo private or move secrets to Key Vault |
Rule of thumb: if the resource group and connections still exist, a restore takes minutes. If the whole subscription is gone, expect hours, mostly re-signing connections and updating callers.
Lessons learned
- Count with the pipeline's identity, not yours. My personal login saw a quarter of the Logic Apps; the service connection saw all of them.
-
Don't trust
az resource listcounts. Paged results can contain duplicates, so count unique resource IDs. - Commit only on change. Your history becomes a log of real portal edits, not daily noise.
- Pin third-party tools. Pin the APIM extractor version so a new release can't change output overnight.
-
Grant the build identity Contribute. Otherwise the first push fails with
TF401027.
Wrap-up
One scheduled pipeline, one protected branch, and one restore pipeline give you daily, tamper-proof history of every Logic App, API connection, and APIM instance. Each morning's commit shows exactly what changed in production the day before, and any version can be put back.
Top comments (0)