Skip to main content

Migrate your Azure Terraform Configuration from AzAPI to AzureRM

·2004 words·10 mins
Terraform Azure Mvpbuzz Microsoft Hashicorp

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:

  • Azure network security perimeter ( docs)
  • Azure DNS security policies ( docs)

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.

Terraform Import Resources by Identity
·676 words·4 mins
Terraform

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).


  1. 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. ↩︎

Mattias Fjellström
Author
Mattias Fjellström
Cloud architect · Author · HashiCorp Ambassador · Microsoft MVP