I recently browsed through Azure updates and found two features that were recently moved to general availability (GA) that I wanted to try out. I often test infrastructure like this starting out with Terraform, unless I am lazy and use ClickOps.
The two new features in GA I wanted to try are:
It seems like “GA” does not mean it is supported in the Terraform AzureRM provider. As far as I can tell, none of these features can be found in the provider.
In situations like these there is a recommended alternative: you can use the AzAPI provider. This provider works with the raw underlying Azure API, and as long as it is supported in the API it should (in theory) be supported by this provider.
Working with the AzAPI provider feels like a hack. So at least for me this is a very last resort. However, this lead me to wonder what the migration journey from the AzAPI provider to the AzureRM provider is like. Will it work flawlessly? That is the topic of this blog post.
There is a tool from Microsoft called aztfmigrate to help you perform these types of migrations at scale, check it out here.
This post is for those who want to understand how this can be done without any third-party tools.
Provision infrastructure using the AzAPI provider#
To have something that we can migrate from the AzAPI provider to the AzureRM provider we need to use established resources that are available in the AzureRM provider already, so I can’t use the new GA features that I originally wanted to explore. Instead we will use a resource group and a storage account.
In my providers.tf
file (or terraform.tf
file or whatever you want to call it) I configure the AzAPI provider:
terraform {
required_providers {
azapi = {
source = "Azure/azapi"
version = "~> 2.5"
}
}
}
provider "azapi" {
subscription_id = var.subscription_id
}
In variables.tf
I have defined a variable for my Azure subscription ID, and another variable for the location where I want to create my Azure resources:
variable "subscription_id" {
description = "Azure subscription ID"
type = string
}
variable "location" {
description = "Azure location name for all resources"
type = string
The AzAPI provider has a small set of resource types available. In fact, there are only four resource types available. We want to create new infrastructure resources, so the resource type we want to use here for both the resource group and the storage account is azapi_resource
.
We configure the resource group first in a main.tf
file:
resource "azapi_resource" "resource_group" {
type = "Microsoft.Resources/resourceGroups@2025-04-01"
name = "rg-azapi-to-azurerm"
parent_id = "/subscriptions/${var.subscription_id}"
location = var.location
}
This looks eerily similar to Bicep code where you need to define the exact resource type with API version for each resource.
Next we configure the storage account in the same main.tf
file:
resource "azapi_resource" "storage_account" {
type = "Microsoft.Storage/storageAccounts@2024-01-01"
name = "stazapitoazurerm"
location = var.location
parent_id = azapi_resource.resource_group.id
body = {
sku = {
name = "Standard_LRS"
}
kind = "StorageV2"
properties = {
accessTier = "Hot"
}
}
}
There is a relationship (an implicit dependency) between the two resources since we reference the resource group resource in the parent_id
argument of the storage account.
Create a terraform.tfvars
file and provide values for the two variables (Azure subscription ID and location). Provision the resources by going through terraform init
, terraform plan
, and terraform apply
:
$ terraform apply -auto-approve
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# azapi_resource.resource_group will be created
+ resource "azapi_resource" "resource_group" {
+ id = (known after apply)
+ ignore_casing = false
+ ignore_missing_property = true
+ ignore_null_property = false
+ location = "Sweden Central"
+ name = "rg-azapi-to-azurerm"
+ output = (known after apply)
+ parent_id = "/subscriptions/<id>"
+ schema_validation_enabled = true
+ sensitive_body = (write-only attribute)
+ type = "Microsoft.Resources/resourceGroups@2025-04-01"
}
# azapi_resource.storage_account will be created
+ resource "azapi_resource" "storage_account" {
+ body = {
+ kind = "StorageV2"
+ properties = {
+ accessTier = "Hot"
}
+ sku = {
+ name = "Standard_LRS"
}
}
+ id = (known after apply)
+ ignore_casing = false
+ ignore_missing_property = true
+ ignore_null_property = false
+ location = "Sweden Central"
+ name = "stazapitoazurerm"
+ output = (known after apply)
+ parent_id = (known after apply)
+ schema_validation_enabled = true
+ sensitive_body = (write-only attribute)
+ type = "Microsoft.Storage/storageAccounts@2024-01-01"
}
Plan: 2 to add, 0 to change, 0 to destroy.
azapi_resource.resource_group: Creating...
azapi_resource.resource_group: Creation complete after 4s [id=<id>]
azapi_resource.storage_account: Creating...
azapi_resource.storage_account: Still creating... [00m10s elapsed]
azapi_resource.storage_account: Still creating... [00m20s elapsed]
azapi_resource.storage_account: Creation complete after 22s [id=<id>]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed
That concludes our work with the AzAPI provider.
Migrate infrastructure to the AzureRM provider#
The AzureRM provider is the main provider for Microsoft Azure, co-developed by HashiCorp and Microsoft.
This provider is a nice abstraction layer on top of the Azure API, with a better developer experience compared to the raw AzAPI provider.
Add the azurerm
provider in providers.tf
and add a provider
block where you configure the provider. Leave the azapi
provider details as-is for now:
terraform {
required_providers {
azapi = {
source = "Azure/azapi"
version = "~> 2.5"
}
# add the azurerm provider
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.39"
}
}
}
provider "azapi" {
subscription_id = var.subscription_id
}
# configure the azurerm provider
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
In main.tf
, start by adding two resource blocks (one for the resource group and one for the storage account) and configure them similarly to how you configured the corresponding resources in the AzAPI provider:
# main.tf
# azapi resources left out for brevity ...
resource "azurerm_resource_group" "default" {
name = "rg-azapi-to-azurerm"
location = var.location
}
resource "azurerm_storage_account" "default" {
name = "stazapitoazurerm"
location = var.location
resource_group_name = azurerm_resource_group.default.name
access_tier = "Hot"
account_replication_type = "LRS"
account_tier = "Standard"
}
If we would blindly remove the AzAPI resources at this stage, and then try to provision the AzureRM resources we would encounter errors saying that resources with these names already exist. You could run a terraform plan
now and it would look OK, saying that two new resources will be created - however, the errors will come when you run terraform apply
.
To solve this we will try to use moved
blocks to perform a configuration-driven refactoring of our Terraform state file. Add one moved
block for each of the two resources:
# main.tf
# other content omitted for brevity ...
moved {
from = azapi_resource.resource_group
to = azurerm_resource_group.default
}
moved {
from = azapi_resource.storage_account
to = azurerm_storage_account.default
}
A moved
block has two arguments:
from
represents the old resource address that you are migrating away from.to
represents the new resource address that you are migrating to.
Run terraform init
again to install the azurerm
provider. Next, run a terraform plan
and … oh no …
$ terraform plan
│ Error: Move Resource State Not Supported
│
│ The "azurerm_resource_group" resource type does not support moving resource state across resource types.
│
│ Error: Move Resource State Not Supported
│
│ The "azurerm_storage_account" resource type does not support moving resource state across resource types.
As it turns out, you can’t simply migrate across resource types unless it is explicitly allowed (i.e. implemented and supported in the providers).
The solution in this case is to instead perform a configuration-driven import of existing infrastructure to our Terraform state.
First remove the two moved
blocks you added previously because we will not be moving anything around in the state anymore. Also remove (or comment out) the two azapi_resource
resources. Your main.tf
file should now look simply like this:
# main.tf
resource "azurerm_resource_group" "default" {
name = "rg-azapi-to-azurerm"
location = var.location
}
resource "azurerm_storage_account" "default" {
name = "stazapitoazurerm"
location = var.location
resource_group_name = azurerm_resource_group.default.name
access_tier = "Hot"
account_replication_type = "LRS"
account_tier = "Standard"
}
Add two removed
blocks to the configuration, one for each of the azapi_resource
resources that we removed:
# main.tf
removed {
from = azapi_resource.resource_group
lifecycle {
destroy = false
}
}
removed {
from = azapi_resource.storage_account
lifecycle {
destroy = false
}
}
The removed
blocks tell Terraform that these resources should be removed from the Terraform state file. We specify destroy = false
in the lifecycle
block, meaning that Terraform will simply remove the resources from the state file but not try to destroy the provisioned resources.
Finally, add two import
blocks to the configuration, one for the resource group and one for the storage account:
# main.tf
import {
to = azurerm_resource_group.default
id = "<id>"
}
import {
to = azurerm_storage_account.default
id = "<id>"
}
The import
blocks tell Terraform that these resources already exist (they are provisioned using some other means) and that they should be imported into the Terraform state file. The to
argument represents the address in the state file where this resource should be imported to. You need to have a corresponding resource
block configured for this to work1.
The id
arguments of the import
blocks represent the resource IDs in Azure. You need to copy these from the Azure portal or Azure CLI output, or alternatively build the resource IDs yourself (because they are constructed from known components). There is a new feature in Terraform where you can import a resource by identity instead of id, but this is not supported in the AzureRM provider yet.
Run a new terraform plan
and observe the output. The relevant portion of the output is shown below:
$ terraform plan
...
Plan: 2 to import, 0 to add, 1 to change, 0 to destroy.
│ Warning: Some objects will no longer be managed by Terraform
│
│ If you apply this plan, Terraform will discard its tracking information for
│ the following objects, but it will not delete them:
│ - azapi_resource.resource_group
│ - azapi_resource.storage_account
│
│ After applying this plan, Terraform will no longer manage these objects.
│ You will need to import them into Terraform to manage them again.
We see that two resources will be imported and two resources will be removed from the state file (but not destroyed). There is one resource that will be changed, this is the storage account where it seems like there is a mismatch of default values between the AzAPI provider and the AzureRM provider. We’ll ignore this difference and let it be.
Apply the configuration by running terraform apply
:
$ terraform apply
...
Apply complete! Resources: 2 imported, 0 added, 1 changed, 0 destroyed.
Summary#
In this blog post I investigated what it takes to migrate from the AzAPI provider to the AzureRM provider. This is a necessary step to take if you want to configure new resource types in Terraform that are not supported by the AzureRM provider yet, but want to use the AzureRM provider once the resource type is supported.
The first attempt was to use moved
blocks to simply transition between two resource types. This was not supported!
The second attempt instead used a combination of removed
blocks and import
blocks to achieve the desired result.
As mentioned previously, using the AzAPI provider feels like a hack. It is closer to using the raw API and it does not feel like a native Terraform provider like the AzureRM provider. If you must configure a new resource on Azure that is not supported in the AzureRM provider then this is one way of doing it. There are other ways, but they might feel even more hacky (e.g. using a null_resource
and a PowerShell script).
you can also use an experimental feature where Terraform generates the configuration for you, but in this simple case we do not need to use it. ↩︎