Installing Terraform and Your First Resource: Providers, init, plan, apply and destroy with a Bookstore Resource Group on Azure
Installing Terraform means putting a single binary on your PATH; the real work is making everyone use the same versions with required_version, provider version constraints and a committed .terraform.lock.hcl. We write Bookstore's resource group and storage account with AzureRM 5.x, look at the resource provider registration change in 5.0, read the +, ~, -/+ and - symbols in real plan output, and try saved plans, stale plans, destroy, .gitignore and fmt.
Version note: The examples in this post were run with Terraform 1.17.2, AzureRM provider 5.14.0, the kreuzwerker/docker provider 4.7.0 and Docker Engine 29. For the Azure code, init, validate and fmt were run; no plan/apply was made against Azure.
Guess First
You change a single line in the definition of the Bookstore API container: you move the image tag from 1.0.0 to 1.1.0. What does the last line of the plan say: 1 to change, or 1 to add, 1 to destroy? Second question: you saved the plan with terraform plan -out=tfplan, and while you were getting coffee a teammate ran an apply against the same environment. When you come back and run terraform apply tfplan, what happens? The answers are in the middle of the post.
Installing Terraform on Windows
Terraform is a single executable; installing it really means putting that file on your PATH. HashiCorp's install page lists no package manager for Windows, only zip files for 386, AMD64 and ARM64. There are three practical ways on Windows:
# 1. winget (community-maintained package)
winget install -e --id Hashicorp.Terraform
# 2. Official zip: download, compare with the value in the SHA256SUMS file, unzip into a folder on PATH
Get-FileHash .\terraform_1.17.2_windows_amd64.zip -Algorithm SHA256
# 3. A version manager: a different version per project
tenv tf install 1.17.2
If you work on a single project, winget is enough. If several repositories are pinned to different Terraform versions, a version manager like tenv makes life easier: it reads the .terraform-version file you put at the project root, or the required_version in the code, picks the right version and also manages OpenTofu. I prepared this post on a machine that is not running Windows; I installed Terraform from the official zip and compared its SHA256 value with the SHA256SUMS file. I did not run the three Windows commands above myself; they come from the HashiCorp and tenv documentation. After installation, the first line of terraform version should read Terraform v1.17.2.
required_version and required_providers: Version Constraints
We split the local example from post 1 into two files. Everything about versions lives in versions.tf:
terraform {
required_version = "~> 1.17"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.7"
}
}
}
required_version states which Terraform versions may run this code. The ~> operator is known as the "pessimistic constraint" and only lets the rightmost part increase: ~> 1.17 means 1.17 or later but below 2.0, while ~> 1.17.2 accepts patch releases between 1.17.2 and 1.18.0. If someone on the team runs an older Terraform, or one outside the constraint, it stops as early as init. When I deliberately set the constraint to >= 1.18, 1.17.2 says:
Error: Unsupported Terraform Core version
on versions.tf line 2, in terraform:
2: required_version = ">= 1.18"
This configuration does not support Terraform version 1.17.2. To proceed,
either choose another supported Terraform version or update this version
constraint. Version constraints are normally set for good reason, so updating
the constraint may lead to other errors or unexpected behavior.
required_providers states which provider comes from which source and in which version range. The source address is its location in the registry: kreuzwerker/docker is a community provider, hashicorp/azurerm is HashiCorp's official provider. Providers ship major versions on their own schedule, and major versions bring breaking changes; AzureRM 4.0 and 5.0 did exactly that. That is why a provider constraint should always stay within one major version: ~> 5.14 takes 5.14 and later 5.x releases, not 6.0.
.terraform.lock.hcl: Why It Is Committed
When terraform init downloads a provider, it writes the exact version it selected and the package hashes to .terraform.lock.hcl:
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/kreuzwerker/docker" {
version = "4.7.0"
constraints = "~> 4.7"
hashes = [
"h1:5Ja6Ls3U05S22eICsWOoJSbkOfNIbvCwtgylDlywZ/w=",
"h1:6hDUAMnx0fxA8sjN05+cuUJKAxuW4N68BZiwZBWmUhg=",
"h1:HzzytbpOhZJuIsTJsxq0iN/mtrTkktS+goYm0+fBV9o=",
"zh:218c89bc881f743d3badd045020015d4ea3fde20837638c6fb8e4425a1330baa",
...
]
}
This file is Terraform's equivalent of package-lock.json or a NuGet lock file. Once committed, everyone on the team and CI use the same provider version, and the hashes verify that the downloaded package has not been tampered with. Even with a ~> 4.7 constraint, if the lock file says 4.7.0 it does not move to 4.8 on its own when 4.8 ships; upgrading is a deliberate step: terraform init -upgrade, then a review of the diff in the lock file.
If you change the constraint so that it contradicts the lock file, Terraform does not guess; it stops. With version set to ~> 3.6:
Error: Failed to query available provider packages
Could not retrieve the list of available versions for provider
kreuzwerker/docker: locked provider registry.terraform.io/kreuzwerker/docker
4.7.0 does not match configured version constraint ~> 3.6; must use terraform
init -upgrade to allow selection of new versions
There is one more trap. The h1: hash is computed from the platform-specific package, and by default init writes only the hash for the platform it runs on. If you create the lock file on Windows and send it to CI running on a Linux runner, CI cannot find a hash to verify the package. The fix is to lock every platform the team uses up front:
terraform providers lock -platform=windows_amd64 -platform=linux_amd64 -platform=darwin_arm64
- Obtained kreuzwerker/docker checksums for windows_amd64; Additional checksums for this platform are now tracked in the lock file
- Obtained kreuzwerker/docker checksums for linux_amd64; Additional checksums for this platform are now tracked in the lock file
- Obtained kreuzwerker/docker checksums for darwin_arm64; All checksums for this platform were already tracked in the lock file
Success! Terraform has updated the lock file.
The three h1: lines in the lock file above are the hashes for these three platforms.
Bookstore's First Azure Resources: A Resource Group and a Storage Account
Now to the real target, the infra/azure folder. The versions file follows the same pattern:
terraform {
required_version = "~> 1.17"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.14"
}
}
}
The provider configuration is in providers.tf:
provider "azurerm" {
# Uses the account signed in with the Az CLI; the subscription ID comes from ARM_SUBSCRIPTION_ID
resource_providers_to_register = ["Microsoft.Storage"]
features {}
}
Two details here are specific to AzureRM 5.x. First, the subscription ID has been mandatory since 4.0: you pass it either with subscription_id in the provider block or with the ARM_SUBSCRIPTION_ID environment variable. According to the upgrade guide, this was done to close the risk of a plan going to whichever subscription happens to be selected in the Azure CLI. Second, resource provider registration. Before a service can be used in a subscription for the first time, that service's resource provider (for example Microsoft.Storage) must be registered. AzureRM 4.x tried to register about sixty resource providers on startup; that slowed startup and caused permission errors for users with restricted rights. In 5.0 the default of resource_provider_registrations became none and skip_provider_registration was removed. You either list what you need with resource_providers_to_register, ask for the old behaviour with resource_provider_registrations = "legacy", or leave registration to a platform team outside Terraform.
The resources are in main.tf:
resource "azurerm_resource_group" "bookstore" {
name = "rg-bookstore-dev-weu"
location = "westeurope"
tags = {
app = "bookstore"
environment = "dev"
managed_by = "terraform"
}
}
resource "azurerm_storage_account" "covers" {
name = "stbookstoredevweu"
resource_group_name = azurerm_resource_group.bookstore.name
location = azurerm_resource_group.bookstore.location
account_tier = "Standard"
account_replication_type = "LRS"
min_tls_version = "TLS1_2"
tags = azurerm_resource_group.bookstore.tags
}
A resource group is the logical container in Azure that holds resources together and can be deleted as a unit. The storage account will hold Bookstore's book covers. Its name must be unique across all of Azure and consist of 3 to 24 lowercase letters and digits, which is why there are no hyphens. The line resource_group_name = azurerm_resource_group.bookstore.name is a reference: from it Terraform infers that the storage account must be created after the resource group. We will look at this dependency graph in detail in post 5.
If you have an Azure account, the flow is: az login with the Azure CLI, get the subscription ID with az account show --query id -o tsv, assign it to $env:ARM_SUBSCRIPTION_ID in PowerShell, then terraform init, plan and apply. The plan says it will add two resources. I did not take this code to Azure; what I ran and the real output:
$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/azurerm versions matching "~> 5.14"...
- Installing hashicorp/azurerm v5.14.0...
- Installed hashicorp/azurerm v5.14.0 (signed by HashiCorp)
...
$ terraform validate
Success! The configuration is valid.
validate only tells you that the code is consistent with itself: the attribute names are right, the types match, the references exist. It does not check whether the storage account name is free in Azure, your subscription's quota or your permissions. Trying to plan without signing in stops the provider before it even reaches Azure:
Error: unable to build authorizer for Resource Manager API: could not configure AzureCli Authorizer: could not parse Azure CLI version: launching Azure CLI: exec: "az": executable file not found in $PATH
with provider["registry.terraform.io/hashicorp/azurerm"],
on providers.tf line 1, in provider "azurerm":
1: provider "azurerm" {
The plan is the step where the provider asks the real world; no credentials, no plan. That is why we learn to read plan output on the local twin.
Reading Plan Output: +, ~, -/+ and -
In the local example we add a restart policy to the container: restart = "unless-stopped". The plan:
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
~ update in-place
Terraform will perform the following actions:
# docker_container.api will be updated in-place
~ resource "docker_container" "api" {
id = "8ece986c7ad708170eb1337ed0d7a075ba09c7c46fee59b005c17a468ed0de61"
name = "tfseries-dev-api"
~ restart = "no" -> "unless-stopped"
# (50 unchanged attributes hidden)
# (2 unchanged blocks hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
~ means an in-place update: the resource stays, only one attribute changes. Because the Docker API can change the restart policy of a running container, the provider planned it as an update. Now we make the change from the first question. First we build the new version of the API and move the image tag in main.tf to 1.1.0:
docker build -t bookstore-api:1.1.0 --build-arg VERSION=1.1.0 app/Bookstore.Api
The plan:
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement
Terraform will perform the following actions:
# docker_container.api must be replaced
-/+ resource "docker_container" "api" {
~ hostname = "8ece986c7ad7" -> (known after apply)
~ id = "8ece986c7ad708170eb1337ed0d7a075ba09c7c46fee59b005c17a468ed0de61" -> (known after apply)
~ image = "bookstore-api:1.0.0" -> "bookstore-api:1.1.0" # forces replacement
- memory = 0 -> null
- user = "1654" -> null
- working_dir = "/app" -> null
...
}
Plan: 1 to add, 0 to change, 1 to destroy.
The answer to the first question: 1 to add, 1 to destroy. The image of a running container cannot be changed, so the provider marks the image attribute as "forces replacement" and Terraform deletes the old container and creates a new one. The real plan is more than eighty lines long; the above is a shortened version. In a long replace plan you see dozens of lines starting with ~ and -: id becoming (known after apply), defaults going back to null. Most of these are a natural consequence of the replace. Look for two things when reading: must be replaced in the resource header and # forces replacement at the end of a line. Those two tell you which attribute triggered the replace.
For a Docker container a replace means a few seconds of downtime. In Azure the same symbol can be far more expensive: if the name or location of an azurerm_storage_account changes, the provider documentation says the resource is recreated. The old storage account is deleted together with all the cover images in it, and an empty one is created in its place. On a database the same symbol means losing the data.
The fourth symbol is -, destroy. terraform plan -destroy plans to delete everything:
# docker_container.api will be destroyed
- resource "docker_container" "api" {
# docker_network.bookstore will be destroyed
- resource "docker_network" "bookstore" {
Plan: 0 to add, 0 to change, 2 to destroy.
I shortened this output with grep; in full, every attribute of every resource is listed with -.
Saved Plans, Stale Plans and destroy
When terraform apply runs without arguments, it plans again, asks for confirmation and applies. If the code or the real world changed in the meantime, what you approve may not be the plan you reviewed. -out closes that gap:
$ terraform plan -out=tfplan
...
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"
$ terraform apply tfplan
docker_container.api: Modifying... [id=8ece986c7ad708170eb1337ed0d7a075ba09c7c46fee59b005c17a468ed0de61]
docker_container.api: Modifications complete after 0s [id=8ece986c7ad708170eb1337ed0d7a075ba09c7c46fee59b005c17a468ed0de61]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
An apply from a saved plan does not ask for confirmation, because you gave it when you reviewed the plan. You can read the contents of a saved plan later with terraform show tfplan. As for the second question: I saved the plan for the image change to tfplan, then applied the same change from another terminal with terraform apply -auto-approve. Trying to apply the saved plan:
Error: Saved plan is stale
The given plan file can no longer be applied because the state was changed by
another operation after the plan was created.
Terraform records the current version of the state along with the plan file. If the state has changed, the plan is stale and will not be applied. In post 11, this behaviour is exactly what makes the "plan job, approval, apply job" flow in CI safe.
When you are done, terraform destroy deletes everything in the state:
docker_container.api: Destroying... [id=5897de26d92d077f394a8dfc57eb59169d4233e151772533b416d893f928edad]
docker_container.api: Destruction complete after 0s
docker_network.bookstore: Destroying... [id=2c7682d8293a661c453ea92809589c7d60564fec3cde46232b1b0ce8c99d84d5]
docker_network.bookstore: Destruction complete after 2s
Destroy complete! Resources: 2 destroyed.
Note the order: on creation the network comes first and then the container; on deletion the container goes first and then the network. Terraform walks the dependency graph in reverse when destroying.
.gitignore and terraform fmt
A Terraform folder has files that must not be committed:
# Provider binaries and the module cache
.terraform/
# State and its backups: they live in a remote backend and may contain secrets
*.tfstate
*.tfstate.*
# Saved plans and crash logs
tfplan
*.tfplan
crash.log
crash.*.log
# CLI configuration files may contain credentials
.terraformrc
terraform.rc
.terraform.lock.hcl is not on this list, because it must be committed. We will see in detail in post 4 why the state must stay out of the repository.
To end formatting debates from the start there is terraform fmt: it brings indentation and = alignment to HCL's standard style. In CI it runs with -check; if a file needs fixing, it prints its name and returns a non-zero exit code. On a deliberately broken file:
$ terraform fmt -check
main.tf
$ echo $?
3
$ terraform fmt -diff
main.tf
--- old/main.tf
+++ new/main.tf
@@ -1,7 +1,7 @@
resource "docker_network" "bookstore" {
- name = "tfseries-dev"
+ name = "tfseries-dev"
labels {
- label = "app"
- value = "bookstore"
+ label = "app"
+ value = "bookstore"
}
}
Production Notes
In production, always apply from a saved plan. The plan that was reviewed and the plan that is applied should be the same file; if the state changes in between, Terraform stops anyway.
Upgrade Terraform, the providers and the lock file together. Make the version upgrade its own pull request, with the relevant lines from the provider changelog and the "No changes." output of the plan after the upgrade in its description. If you put a code change and a version upgrade in the same PR, you cannot tell which one a difference in the plan came from.
Manage resource provider registration deliberately with AzureRM 5.x. In production subscriptions, it is safer for the platform team to register the required ones once than to give the identity running Terraform the right to "register every resource provider".
Common Pitfalls
Running apply -auto-approve without reading the plan. Especially when you think it is "a small change": if you do not know that an attribute triggers a replace, the 1 to destroy on the last line of the plan is your only warning.
Missing the -/+ symbol and recreating the database or the storage account. A resource you rename to fit a "tidier" naming convention is, in Azure, a resource that is deleted and created again. If a name really has to change, it is done together with a data migration plan; to change the address on the Terraform side there is the moved block in post 8.
Adding the lock file to .gitignore, or committing it with only your own platform's hash. The first leaves everyone on the team on a different provider version; the second makes CI reject the lock file.
A Small Experiment
★☆☆ Set up the local example with terraform init, change the restart value and plan with -out=tfplan. Read the output of terraform show tfplan, then apply the plan.
★★☆ Change the external value in the ports block and run a plan. Is this change in-place or a replace? On which line does # forces replacement appear?
★★★ Add allow_nested_items_to_be_public to the storage account in the infra/azure code and run validate. Then find the default value of this attribute in the AzureRM 5.0 upgrade guide and explain why a plan could show a difference on this line in a project moving from 4.x to 5.x.
In the next post we move to HCL itself: variables, outputs and locals, the difference between count and for_each, for expressions and dynamic blocks. Bookstore's Azure infrastructure will also grow with an App Service Plan, a Web App and Azure SQL.