Mert Özen Further With Every Line
DevOps Infrastructure as Code: Terraform — #12

From Terraform 1.5 to 1.17 and AzureRM 3 to 5: What Changed and How to Read Old Code

Mert Özen Nov 14, 2026 13 min 17 views
From Terraform 1.5 to 1.17 and AzureRM 3 to 5: What Changed and How to Read Old Code

Moving old Terraform code to today's version means doing syntax fixes, address moves and the migration of types the provider removed in separate steps, seeing an empty plan at each step. We run 0.11-era code with 1.17, move null_resource to terraform_data, go through the version map from 1.5 to 1.17 and how -minimal-refresh hides drift, and try the upgrade order from AzureRM 3 to 5 and the real compatibility gaps with OpenTofu.

Version note: The examples in this post were run with Terraform 1.17.2, OpenTofu 1.13.9, the hashicorp/null provider 3.3.2, the kreuzwerker/docker provider 4.7.0 and Docker Engine 29. The AzureRM examples were only run through validate with 5.14.0; no plan/apply was made against Azure. The version map was compiled from Terraform's official changelogs.

Guess First

In an old repo of Bookstore (the online bookstore used as the shared example across this site's series) you find a Terraform folder from 2019: every value is inside "${...}", lists are built with list(...), a null_resource runs a migration with a provisioner, and on the Azure side there are azurerm_app_service_plan and azurerm_sql_server. If you init this code with today's Terraform 1.17 and AzureRM 5.14 and run validate, how many errors do you get? Which ones are just formatting, and which ones are real migration work?

Reading Old Code: From 0.11 to 1.x

Terraform's language went through a big change in version 0.12 in 2019. Up to 0.11 every expression was written inside a string interpolation ("${...}"); with 0.12, expressions became a direct part of the language. 0.13 brought source addresses for providers (hashicorp/azurerm) and count/for_each for modules, 0.14 the .terraform.lock.hcl, and 0.15 the removal of old functions. 1.0 (2021) came with a compatibility promise for the language and the state format. An old folder looks like this:

variable "environment" {
  default = "dev"
}

variable "image" {
  default = "bookstore-api:1.1.0"
}

locals {
  data_sets = "${list("covers", "exports")}"
}

resource "null_resource" "migrate" {
  triggers = {
    image = "${var.image}"
  }

  provisioner "local-exec" {
    command = "echo migrating ${var.environment} to ${var.image}"
  }
}
$ terraform validate

Error: Error in function call

  on main.tf line 10, in locals:
  10:   data_sets = "${list("covers", "exports")}"
    ├────────────────
    │ while calling list(vals...)

Call to function "list" failed: the "list" function was deprecated in
Terraform v0.12 and is no longer available; use tolist([ ... ]) syntax to
write a literal list.

The error message says what to do. After changing list(...) to tolist([...]), validate passes: strings made only of an interpolation, like "${var.image}", are still valid in 1.17. terraform fmt fixes some of them by itself:

$ terraform fmt -diff
main.tf
--- old/main.tf
+++ new/main.tf
@@ -7,7 +7,7 @@
 }
 
 locals {
-  data_sets = "${tolist(["covers", "exports"])}"
+  data_sets = tolist(["covers", "exports"])
 }
 
 resource "null_resource" "migrate" {

What stands out is that fmt only unwrapped the expression in the local; it did not touch "${var.image}" inside the triggers map. When modernising old code, do not rely on fmt and skip the rest; find the remaining "${ with grep.

Two more habits are common in old code. First, every layer reading the others with terraform_remote_state; in post 7 we saw why that needs access to the whole state and why data sources or published values are preferred. Second, the "run a script during apply" pattern with null_resource and provisioners. Provisioners still exist, but HashiCorp's documentation describes them as a last resort; today part of that work is done with actions, introduced in 1.14, and part of it in the application's own deploy step.

From null_resource to terraform_data

null_resource came from a separate provider (hashicorp/null). Since Terraform 1.4, the built-in terraform_data does the same job. Since 1.9, a moved block works between the two; the resource is not recreated even though its type changes. I applied the old version (the provisioner ran once), then turned it into this:

variable "environment" {
  type    = string
  default = "dev"
}

variable "image" {
  type    = string
  default = "bookstore-api:1.1.0"
}

locals {
  data_sets = ["covers", "exports"]
}

resource "terraform_data" "migrate" {
  triggers_replace = { image = var.image }

  provisioner "local-exec" {
    command = "echo migrating ${var.environment} to ${var.image}"
  }
}

moved {
  from = null_resource.migrate
  to   = terraform_data.migrate
}
$ terraform plan
...
  # null_resource.migrate has moved to terraform_data.migrate
    resource "terraform_data" "migrate" {
        id               = "7941439726031055028"
        # (1 unchanged attribute hidden)
    }

Plan: 0 to add, 0 to change, 0 to destroy.

The plan says "has moved" and the provisioner does not run again; triggers moved into triggers_replace. In post 8 we saw address changes within the same type; moving between different types depends on provider support. Terraform 1.8 gave providers that ability, but not every provider supports it for every type.

Version Map: From 1.5 to 1.17

Most of the examples in this series rely on features that arrived in the last two years. When you do not see a feature in old code, the reason is usually that the code was written before it existed. The map I compiled from the changelogs:

VersionDateHighlightsIn this series
1.5June 2023import and check blocks, -generate-config-out, plantimestamp, strcontains5, 8
1.6October 2023terraform test GA, expressions in import id; first release under the BSL1, 10
1.7January 2024mock_provider, the removed block, for_each in import8, 10
1.8April 2024Provider-defined functions, moved across types12
1.9June 2024Variable validation that refers to other values, templatestring, null_resource to terraform_data6, 12
1.10November 2024Ephemeral values and resources, native locking in the S3 backend9
1.11February 2025Write-only arguments, S3 native locking GA9
1.12May 2025identity in import, parallel execution in tests8
1.13August 2025The terraform stacks command, test improvements-
1.14November 2025list blocks and terraform query, actions; prevent_destroy ignored during test cleanup8, 10
1.15April 2026Module sources with const variables, deprecated, convert, validate checking the backend3, 6, 7
1.16August 2026lifecycle { destroy = false }, import inside modules, graph -format=mermaid, destroy events for actions5
1.17October 2026Variables in provider requirements, -minimal-refresh, Terraform Policy GA, mocking ephemeral resources10, 12

You can read the minimum version a piece of code needs from required_version, but that line is often not kept up to date. The blocks in use are a more reliable hint: moved means at least 1.1, an import block 1.5, removed 1.7, ephemeral 1.10, const 1.15.

What 1.17 Brought

When this series was written, the current version was 1.17.2; 1.17.0 came out in October 2026. The main additions are these. Provider version constraints can now use variables and locals; similar to the module ref in post 7, the provider version can also be managed from one place across environments. Terraform Policy became GA (post 10). mock_provider can also mock ephemeral resources. A format_version field was added to the output of terraform version -json:

$ terraform version -json
{
  "format_version": "1.0",
  "terraform_version": "1.17.2",
  "provider_selections": {
    "registry.terraform.io/hashicorp/http": "3.6.3",
    "registry.terraform.io/kreuzwerker/docker": "4.7.0"
  },
  "terraform_outdated": false
}

Scripts that read this output can now check the version of the format. The addition that made me think most, though, was -minimal-refresh: plan refreshes only the resources that have proposed changes. On large states it can make plans noticeably faster, but when I tried it in the local dev environment after stopping the container by hand, this was the result:

$ docker stop tfseries-dev-api
$ terraform plan -minimal-refresh
...
Plan: 0 to add, 0 to change, 0 to destroy.
$ terraform plan
...
  # module.bookstore.module.api.docker_container.this must be replaced
...
Plan: 1 to add, 0 to change, 1 to destroy.

Drift does not show up on a resource that is not refreshed. Never use this flag in the drift job from post 11; it is only useful in the development loop where you want to see the effect of a code change quickly.

AzureRM from 3 to 4, and from 4 to 5

AzureRM major versions mean removed resources and changed defaults. 4.0 (August 2024) made two things mandatory: passing the subscription ID explicitly (subscription_id or ARM_SUBSCRIPTION_ID), and choosing resource provider registration with resource_provider_registrations; skip_provider_registration was deprecated. Old SQL resources such as azurerm_sql_server and azurerm_sql_database were removed. 5.0 (July 2026) changed the default of resource_provider_registrations to none, removed skip_provider_registration entirely, moved enhanced_validation inside the features block and turned it off by default, and deleted long-deprecated resources such as azurerm_app_service, azurerm_app_service_plan and azurerm_function_app. The resource_providers_to_register list from post 2 is a result of this change. I tried the 3.x-era code from the guess with 5.14:

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.14"
    }
  }
}

provider "azurerm" {
  features {}
  skip_provider_registration = true
}

resource "azurerm_app_service_plan" "bookstore" {
  name                = "asp-bookstore-dev-weu"
  location            = "westeurope"
  resource_group_name = "rg-bookstore-dev-weu"
  kind                = "Linux"
  reserved            = true

  sku {
    tier = "Basic"
    size = "B1"
  }
}

resource "azurerm_sql_server" "bookstore" {
  name                         = "sql-bookstore-dev-weu"
  resource_group_name          = "rg-bookstore-dev-weu"
  location                     = "westeurope"
  version                      = "12.0"
  administrator_login          = "bookstoreadmin"
  administrator_login_password = var.sql_admin_password
}

variable "sql_admin_password" {
  type      = string
  sensitive = true
}
$ terraform validate

Error: Unsupported argument

  on main.tf line 12, in provider "azurerm":
  12:   skip_provider_registration = true

An argument named "skip_provider_registration" is not expected here.

$ terraform validate

Error: Invalid resource type

  on main.tf line 15, in resource "azurerm_app_service_plan" "bookstore":
  15: resource "azurerm_app_service_plan" "bookstore" {

The provider hashicorp/azurerm does not support resource type
"azurerm_app_service_plan".

Error: Invalid resource type

  on main.tf line 28, in resource "azurerm_sql_server" "bookstore":
  28: resource "azurerm_sql_server" "bookstore" {

The provider hashicorp/azurerm does not support resource type
"azurerm_sql_server". Did you mean "azurerm_mssql_server"?

The first error is just a provider setting and is fixed in one line. The other two are real migrations: 5.x does not know these types at all. There is an important order here. If you upgrade a root whose state contains azurerm_app_service_plan straight to 5.x, the provider cannot read that resource from state. The migration has to happen before the upgrade, on the last 4.x release that still knows the old type: write the code for the new type (azurerm_service_plan, azurerm_linux_web_app, azurerm_mssql_server), let go of the old resource with removed and destroy = false as in post 8, and attach the new one to the same Azure resource with an import block. As long as the provider does not support moved between the two, that is the way.

The general order of a major upgrade follows from this: first move to the last release of your current major and clear all deprecation warnings; read the upgrade guide as a checklist; update the lock file to the new version with terraform init -upgrade; take a plan for every environment and fix the code until you see "No changes". If the plan shows a replace, the upgrade is changing infrastructure; do not apply until you have found the reason for that line in the guide. AzureRM's 6.0 upgrade guide has already started to appear; deprecation warnings are the to-do list for the next major.

OpenTofu Differences and the Decision to Switch

In post 1 we also ran the same code with OpenTofu. Throughout the series we used Terraform's new features; how compatible is it still? I tried Bookstore's code as of the end of post 11 with OpenTofu 1.13.9:

$ cd infra/local/envs/dev
$ cp .terraform.lock.hcl .terraform.lock.hcl.orig
$ tofu init -backend=false && tofu validate
...
Warning: Dependency lock file entries automatically updated
...
Success! The configuration is valid.
$ diff .terraform.lock.hcl.orig .terraform.lock.hcl
1c1
< # This file is maintained automatically by "terraform init".
---
> # This file is maintained automatically by "tofu init".
4c4
< provider "registry.terraform.io/hashicorp/http" {
---
> provider "registry.opentofu.org/hashicorp/http" {
...

$ cd ../../../modules/bookstore-webapp && tofu test
tests/webapp.tftest.hcl... fail
  run "https_and_tls"... fail

Error: expected "principal_id" to be a valid UUID, got ZYlr
...
Failure! 0 passed, 1 failed, 2 skipped.

Three things came out. The code is valid in OpenTofu: write-only arguments, ephemeral resources and the deprecated attribute exist in OpenTofu too. The lock file changes, because OpenTofu pulls providers from its own registry (registry.opentofu.org); if both tools are used in the same repo, the lock file flips back and forth between them. The mocked test from post 10, however, broke in OpenTofu: OpenTofu passes mock values in plan mode through the provider's validation, and since the randomly generated principal ID is not a UUID, the test stops. The problem we only met in apply mode with Terraform shows up in plan mode here. The two tools do not run the same test file the same way.

Then there is required_version. In my trial, OpenTofu ignored the constraint in a .tf file entirely, but enforced the same line when it was in a file with the .tofu extension:

$ cat versions.tf
terraform {
  required_version = "= 99.0.0"
}
$ tofu init
...
OpenTofu has been successfully initialized!

$ mv versions.tf versions.tofu && tofu init
...
Error: Incompatible module

  on versions.tofu line 2, in terraform:
   2:   required_version = "= 99.0.0"

This module is not compatible with OpenTofu v1.13.9.

OpenTofu reads .tofu files together with the .tf files in the same folder and prefers the .tofu one when the names match; Terraform does not read .tofu files at all. That is the way to keep each tool's own settings in the same repo. I tried two OpenTofu-only features this way, in infra/examples/opentofu/main.tofu:

# OpenTofu only: Terraform ignores .tofu files
terraform {
  required_version = ">= 1.11"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.7"
    }
  }

  # Client-side state and plan encryption; the passphrase never lands in a file
  encryption {
    key_provider "pbkdf2" "passphrase" {
      passphrase = var.state_passphrase
    }

    method "aes_gcm" "default" {
      keys = key_provider.pbkdf2.passphrase
    }

    state {
      method = method.aes_gcm.default
    }

    plan {
      method = method.aes_gcm.default
    }
  }
}

variable "state_passphrase" {
  type      = string
  sensitive = true
}

variable "with_reports" {
  type    = bool
  default = false
}

provider "docker" {}

resource "docker_volume" "covers" {
  name = "tfseries-tofu-covers"
}

# One or zero instances without count: the address stays docker_volume.reports
resource "docker_volume" "reports" {
  name = "tfseries-tofu-reports"

  lifecycle {
    enabled = var.with_reports
  }
}
$ export TF_VAR_state_passphrase=<passphrase>
$ tofu apply
docker_volume.covers: Creating...
docker_volume.covers: Creation complete after 0s [id=tfseries-tofu-covers]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ head -c 160 terraform.tfstate
{"serial":1,"lineage":"646b64e9-7d89-c5df-c092-58d50b6a647a","meta":{"key_provider.pbkdf2.passphrase":"eyJzYWx0IjoiSk1MMmhJZW10blROWkdNSWRJdUVzS2hGSStqbUJG
$ tofu apply -var with_reports=true
docker_volume.reports: Creating...
docker_volume.reports: Creation complete after 0s [id=tfseries-tofu-reports]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ tofu state list
docker_volume.covers
docker_volume.reports

$ terraform show
Error: Unsupported state file format

The state file does not have a "version" attribute, which is required to
identify the format version.

$ TF_VAR_state_passphrase=wrong-passphrase-123 tofu plan
Error: Error acquiring the state lock

Error message: failed to write backup file: decryption failed for all
provided methods

The state file holds only encrypted data instead of the resources; with the wrong passphrase OpenTofu cannot open the state, and Terraform does not even recognise the file. In post 9 we saw that state keeps secrets in plain text; OpenTofu solves that by encrypting on the client side. I used a passphrase as the key provider here; in real use the key comes from Azure Key Vault or another KMS. lifecycle { enabled = ... } replaces the count = var.x ? 1 : 0 pattern, and the resource's address stays the same without a [0].

For the decision to switch, the criteria from post 1 still apply: licence (OpenTofu MPL 2.0, Terraform BSL 1.1), the features you need, and the ecosystem. Some of the features we used in this series (const variables, list/query, Terraform Policy, the 1.16 and 1.17 additions) exist only in Terraform, while state encryption and enabled exist only in OpenTofu. Compatibility shrinks as the two projects go their own ways; a team should pick one, not both, and pin its version in CI. Writing the choice and its reasons down as an ADR answers the "why this tool?" question of whoever reads this code two years from now.

Production Notes

Manage the Terraform version like an infrastructure dependency. Keep required_version narrow (~> 1.17), pin the exact version in CI (terraform_version: 1.17.2 from post 11), and do the upgrade in a separate pull request with plans for every environment. A state written once by a newer Terraform version may not open with an older one; an upgrade is a step that is hard to undo.

Do not postpone provider major upgrades, but do not rush them either. Deprecation warnings turn into errors in the next major; upgrading code with piled-up warnings means migrating dozens of resources at once. Clearing warnings on every minor upgrade turns the major into an ordinary change.

Do not change behaviour while modernising old code. Syntax fixes, address moves with moved and type changes should go in separate commits, each with a plan that says "No changes" or only "has moved". If a SKU changes in the same commit as a syntax fix, you cannot tell the reason for the replace in the plan.

Common Pitfalls

Applying old code straight away with the newest version. Code from the 0.11 era may pass validate with 1.17, but defaults and provider behaviour that changed in the versions in between can bring unexpected changes in the plan. Plan first, then fix step by step.

Upgrading the provider by skipping a major version. Going from AzureRM 3 to 5 in one step means handling the changes from two upgrade guides at once and ending up with a state full of removed types. Stop at every major.

Mixing Terraform and OpenTofu at random in the same repo. The lock file keeps changing, tests behave differently, and one tool's feature is silently ignored by the other. If you are going to support both, keep the differences in .tofu files and run both in CI.

A Small Experiment

★☆☆ In an old Terraform folder of your own, find the strings made only of an interpolation with grep -rn '"\${' --include=*.tf .. How many of them did terraform fmt fix?

★★☆ Write a version of infra/examples/opentofu/main.tofu that uses count instead of enabled and compare the address of the same resource in both versions. Do you need a moved block when switching from count to enabled?

★★★ Rework the bookstore-webapp test from post 10 so it passes with both Terraform 1.17 and OpenTofu 1.13 (hint: mock_resource defaults). What does this tell you about the cost of supporting both tools in a team?

We have reached the end of the series. Bookstore's infrastructure is now in code: a state per environment, modules, resources that keep secrets out of state, tests, and a pipeline that applies after approval. Getting the application into production on top of this infrastructure, deployment strategies and the Kubernetes side are the subject of the site's CI/CD and Kubernetes series.