DevOps & Infrastructure

Terraform to Bicep Migration on Azure: A Field Guide

By Falak MahmoodAugust 31, 202617 views

This is a guide for teams that have decided, or are close to deciding, to move their Azure infrastructure-as-code from Terraform to Bicep. It is not a comparison piece. If you are still weighing the two tools, start with our Bicep vs Terraform comparison and come back when the direction is set. Here we cover the migration itself: what to inventory, how to run both tools side by side without them fighting, which conversion tooling exists (and which does not), what changes in your pipelines, and how to keep a rollback path open the whole way through.

Should you migrate from Terraform to Bicep at all?

The first migration decision is whether to migrate. A working Terraform estate is an asset, and rewriting working infrastructure code produces zero user-visible value while it is in flight. The cases where a move from Terraform to Bicep pays for itself are specific:

  • State management is a recurring operational burden. Corrupted or locked tfstate, secrets leaking into state files, backend access management, and the on-call knowledge needed to do state surgery. If your team keeps paying this tax on an Azure-only estate, removing the state file removes the whole category.
  • You are Azure-only and expect to stay that way. The Terraform ecosystem argument evaporates when every provider block in your codebase says azurerm.
  • Your organisation is standardising on Microsoft-native tooling. If the platform team, support contracts and hiring pipeline are all Azure-centred, a second IaC toolchain is a cost with no offsetting benefit.
  • You inherited ARM templates alongside Terraform. Bicep decompiles ARM JSON directly, so consolidating a mixed ARM-plus-Terraform estate into Bicep is often less work than consolidating it into Terraform.

The cases for staying are equally specific: any multi-cloud footprint, heavy use of non-Azure providers (Cloudflare, GitHub, Datadog, databases), or a large, stable Terraform estate run by a team that has already mastered state operations. In those situations, the right move is usually to standardise new Azure-only projects on Bicep and let the Terraform estate live out its life. Migration is a spectrum, not a switch.

Inventory: know what your tfstate is holding

Before converting anything, list what Terraform actually manages. The state file is your migration scope document:

terraform state list > inventory.txt
terraform show -json > state-snapshot.json

Classify every entry into one of four buckets, because each bucket has a different migration path:

  • Plain Azure resources. Storage accounts, virtual networks, app services. These map to Bicep resource declarations and are the bulk of the mechanical work.
  • Azure resources with Terraform-side logic. Resources wrapped in count/for_each loops, computed locals, or cross-module references. These need a hand-written Bicep design, not a conversion.
  • Non-Azure providers. random, tls, dns, github, anything third-party. Bicep cannot express these. Each one either stays in a residual Terraform workspace, moves to a script or pipeline step, or gets replaced by an Azure-native equivalent (Key Vault for generated secrets, for example).
  • Data sources and provisioners. Data sources usually become Bicep existing references or parameters. Provisioners have no Bicep equivalent and belong in your pipeline instead.

This classification is also your effort estimate. Count the resources in each bucket and cost them differently: bucket one is minutes per resource, bucket two is hours per module, and buckets three and four are design decisions that can each block a cutover. A workspace that is ninety percent bucket one migrates in days; a workspace with a dozen non-Azure providers may never be worth finishing, and that is a legitimate outcome of the inventory.

From tfstate to no state: what you are actually replacing

The conceptual shift matters more than the syntax shift. Terraform owns a private database of what it created; Bicep does not. A Bicep deployment sends the desired state to Azure Resource Manager, and ARM reconciles it against the live resources it already knows about. Two consequences follow, one pleasant and one that needs a plan.

The pleasant one: there is no import step. Terraform migrations in the other direction stall on importing hundreds of resources into state. Bicep has no state to import into. Write a Bicep file whose resource names, types and scopes match what is deployed, and the first deployment adopts those resources in place, updating properties where the template differs. Your migration validation loop is simply what-if, which we cover below.

The one that needs a plan: nothing tracks deletions by default. Remove a resource from a Bicep file and redeploy, and the resource just keeps running. Terraform teams rely on plan showing destroys; vanilla Bicep will not. The answer is Azure deployment stacks, a Resource Manager feature that tracks the set of resources a template manages and acts when one leaves the template. You choose the behaviour with actionOnUnmanage: detachAll leaves removed resources running, deleteResources deletes them, deleteAll deletes resources and resource groups. Stacks also support deny settings (denyDelete or denyWriteAndDelete) that block out-of-band changes to managed resources, with up to five excluded principals, so use Entra ID groups for exclusions. Deployment stacks require Azure CLI 2.61.0+ or Azure PowerShell 12.0.0+.

az stack group create \
  --name platform-network \
  --resource-group rg-network-prod \
  --template-file main.bicep \
  --action-on-unmanage detachAll \
  --deny-settings-mode none

For a team leaving Terraform, a stack per former Terraform workspace is the closest structural analogue: it restores the "what do I manage, and what happens on removal" answers that tfstate used to give you, without the file.

Incremental migration strategies: coexistence beats big bang

The single rule that makes coexistence safe: every resource has exactly one owner at any moment. Terraform and Bicep can share a subscription, a resource group, even a virtual network, indefinitely. What they cannot share is a resource, because Terraform will treat Bicep's property changes as drift to revert. Migration is the act of moving ownership, resource by resource or group by group. Three sequencing strategies, in ascending order of ambition:

  • 1. New resources in Bicep first. Freeze the Terraform estate for new work and write everything new in Bicep. Zero migration risk, immediate skills building, and the Terraform surface starts shrinking by attrition. Every team should start here regardless of the end goal.
  • 2. Migrate on change. When a Terraform-managed component needs meaningful modification anyway, port it to Bicep as part of that work. The testing cost is already budgeted, so the migration rides along at a discount. Over a year or two this converts the actively maintained parts of the estate, which are the parts that matter.
  • 3. Deliberate cutover, one workspace at a time. For each Terraform workspace: port the config to Bicep, validate with what-if until the diff is clean, release the resources from Terraform state, then create a deployment stack over them. Resource-group-sized units keep the blast radius reviewable.

Releasing resources from Terraform without destroying them

The hand-off step uses Terraform's own tooling. The modern, reviewable way is a removed block with destroy disabled, which goes through your normal plan and apply flow:

# In your Terraform config, replacing the resource block:
removed {
  from = azurerm_storage_account.logs

  lifecycle {
    destroy = false
  }
}

HashiCorp documents this as removing the resource from state "without changing the underlying infrastructure", which is exactly the hand-off semantics a migration needs. The older imperative equivalent is terraform state rm, which also leaves the real resource untouched but bypasses plan review, so prefer the removed block in shared codebases. Either way the resource keeps running, unmanaged, until your Bicep deployment or stack adopts it. Do the release and the adoption in the same change window so the unmanaged gap stays short.

Convert Terraform to Bicep: the tooling that exists

Set expectations correctly: there is no supported converter that turns HCL into Bicep. The Bicep decompiler accepts ARM JSON templates, not Terraform files, and Microsoft's decompile documentation is explicit that even for ARM JSON there is "no guaranteed mapping" and the output may need fixing. If a third-party tool claims one-shot Terraform-to-Bicep conversion, verify it against a real module before you plan around it.

What Microsoft does document is a path that works because your infrastructure is already deployed. You do not convert the Terraform code; you convert the live resources it created:

# Export the deployed resource group as ARM JSON, then decompile to Bicep
az group export --name rg-network-prod > exported.json
az bicep decompile --file exported.json

Treat the output as scaffolding, not as finished code. Exported templates capture every server-populated default, so the raw decompile is verbose; parameter names containing periods get rewritten with underscores; symbolic names come out awkward. The VS Code Bicep extension helps here: it can paste ARM JSON as Bicep, decompile files in place, and insert resource declarations imported from existing Azure resources. Expect to spend most of the conversion effort not on syntax but on re-establishing structure: turning the flat export back into parameterised modules, ideally leaning on Azure Verified Modules for the common resource types instead of hand-porting your old Terraform modules one property at a time.

CI/CD pipeline changes

Your pipeline shape survives the migration; the steps inside it change. The Terraform gate of plan, human review, apply becomes what-if, human review, deploy:

# PR validation
az bicep build --file main.bicep
az deployment group what-if \
  --resource-group rg-network-prod \
  --template-file main.bicep \
  --parameters prod.bicepparam

# Deploy after approval (or gate interactively)
az deployment group create \
  --resource-group rg-network-prod \
  --confirm-with-what-if \
  --template-file main.bicep \
  --parameters prod.bicepparam

Things you get to delete from the pipeline: the state backend configuration, the state locking logic, the credentials that existed only so CI could reach the backend, and any Terraform Cloud or third-party runner subscription that existed only to hold state. Things you add: nothing mandatory beyond the CLI steps above, though from Azure CLI 2.76.0 the what-if and create commands accept a validation-level switch (Provider, ProviderNoRbac, Template) that lets a PR pipeline validate templates without holding full deployment permissions. If you adopt deployment stacks, the deploy step becomes az stack group create, and your service connection needs the stack RBAC roles (Azure Deployment Stack Contributor or Owner) when deny settings are in play.

Validation: what-if is your migration safety net

During cutover, what-if is not just a preview, it is the acceptance test. Run it with the ported Bicep template against the live resource group before releasing anything from Terraform, and read the result with migration eyes:

  • Create entries are red flags. A Create against a resource that already exists means your name, type or scope does not match the deployed resource, and deploying would produce a duplicate rather than an adoption. Fix the template until the Create disappears.
  • Modify entries need a property-level read. Some are real differences between your Terraform-era configuration and the template; some are what-if noise. Microsoft documents that properties absent from the template but set as service defaults can be incorrectly reported as deleted, and that expressions such as reference(), listKeys(), secure parameters and utcNow() cannot be evaluated outside a deployment, so they always show as changes.
  • NoChange across the board is the finish line. When what-if reports the estate as materially unchanged, the Bicep template describes reality and the ownership transfer is safe.

If you use deployment stacks, the what-if operation also previews stack changes, including which resources an update would detach or delete. Make that preview a mandatory pipeline step before any stack update that runs with deleteResources, because that flag is where a template mistake turns into a deletion.

Rollback safety

A migration you cannot reverse is a gamble, and this one is cheap to make reversible:

  • Freeze, do not delete, the Terraform artefacts. Tag the final commit, snapshot the tfstate, and keep the backend readable. Because releasing resources with destroy = false never touched the infrastructure, rolling back is re-adding the resource blocks and importing the resources back into state.
  • Run stacks with detachAll during the transition. Start every new deployment stack in detach mode, where the worst case of a template mistake is an unmanaged resource, not a deleted one. Tighten to deleteResources only after the stack has been through a few routine updates and the what-if previews have earned trust.
  • Add deny settings last. denyWriteAndDelete is excellent protection for a settled estate and an obstacle while you are actively cutting over. Sequence it after the rollback window closes.
  • Define the rollback window explicitly. Agree upfront how long the frozen Terraform path stays viable per workspace, for example until the Bicep estate has survived a production change cycle. An open-ended rollback promise quietly forces the team to keep two toolchains warm forever.

The Swedish and EU angle

Two aspects of this migration land differently for Swedish and EU organisations. The first is data governance: a tfstate file contains resource properties and, depending on your providers, sensitive values, and it lives wherever your backend lives. Teams using a US-hosted SaaS backend for state have had to account for that flow in GDPR transfer assessments. The Bicep model removes the artefact entirely: desired state lives in your repo, applied state lives in Azure Resource Manager in your chosen region, and there is no third state copy to document, secure or answer questionnaires about.

The second is procurement and staffing. Public-sector and enterprise Azure agreements in the Nordics already cover ARM, and Bicep rides on it with no additional licence, vendor or support relationship, which simplifies the upphandling conversation compared with adding a commercial IaC platform. On staffing, an Azure-only consultancy bench or internal platform team can be productive in Bicep quickly because the resource model is the ARM model they already navigate in the portal and CLI. That does not make migration free, but it does mean the skills cost is front-loaded and small relative to the ongoing cost of running two toolchains.

The migration checklist

Order matters. Every step below leaves the estate deployable and reversible. If a step fails, you stop where you are with two working toolchains, not one broken one.

  • 1. Confirm the decision holds: Azure-only, state pain is real, no multi-cloud plans. Otherwise standardise new work on Bicep and stop here.
  • 2. Inventory tfstate with terraform state list and classify every resource: plain Azure, logic-heavy, non-Azure provider, data source or provisioner.
  • 3. Decide the fate of every non-Azure provider before porting anything, since these are your only hard blockers.
  • 4. Point all new work at Bicep immediately; freeze the Terraform estate for changes that are not migrations.
  • 5. Per workspace: port to Bicep using az group export plus az bicep decompile as scaffolding, then restructure into modules.
  • 6. Validate with az deployment group what-if until no Create entries remain and every Modify is explained.
  • 7. Release resources from Terraform with removed blocks (destroy = false), then deploy the Bicep template, or create a deployment stack with detachAll, in the same change window.
  • 8. Update pipelines: replace plan and apply with what-if and deploy, remove the state backend and its credentials.
  • 9. After a stable period, tighten stacks (deleteResources, deny settings) and archive the frozen Terraform code and state.

Sources

Related posts

DevOps & Infrastructure
Bicep vs Terraform in 2026: Which to Choose for Azure

A 2026 comparison of Bicep and Terraform for Azure — state management, provider coverage, drift detection, module ecosystems, team-scale considerations, and an honest recommendation matrix for different team shapes.

DevOps & Infrastructure
Self-Hosted GitHub Runners on Azure: The ROI Calculation

The honest cost model for self-hosted GitHub Actions runners on Azure — break-even math against hosted runners, when self-hosting is a trap, and the ephemeral scaleset architecture that keeps them safe in production.

Azure & Cloud
Azure Assistants API retired: migrating to Foundry Agents

The Azure OpenAI Assistants API reached its retirement date on 26 August 2026, and the classic Foundry Agent Service it underpins retires 31 March 2027. A step-by-step migration guide to the new Foundry Agent Service on the Responses API: threads become conversations, runs become responses, assistants become versioned agents, and Microsoft's migration tool rewrites code but not stored state.

Next.js & React
Upgrading to Next.js 16.3: build cache and memory wins

Next.js 16.3 shipped on 3 August 2026 with build disk caching on by default, up to 90% lower dev-server memory through eviction, TypeScript 7 type checking, and native Node.js streams that handle up to 22% more SSR requests under load. Here is what an Azure-hosted enterprise app gains from the upgrade, how to verify the wins in your own CI pipeline, and a decision guide for the opt-in Instant Navigations suite.