The problem
State is a secret, and a whole team needs the same copy of it. A file on your laptop fails both tests: your colleague's plan cannot see it, and anyone who gets your laptop gets every password in it.
So state has to live somewhere shared, protected and lockable. The setting that says where is the backend. This lesson is about choosing one, pointing Terraform at it, and moving existing state into it without losing anything.
What you need to know already:
- what state is and why it is a secret (13.1)
- the
terraform { }block andinit(12.2, 12.3) - what a storage account and a resource group are (Ch 12)
- environment variables (2.21) - credentials often come in through them
What a backend is
The backend decides where state is stored and whether it can be locked (locked = only one run may write at a time; 13.11).
In Terraform 1.x a backend is just storage. (Older versions had "enhanced" backends that also ran your commands remotely; today that job belongs to HCP Terraform's cloud block, at the end of this lesson.)
The common ones:
local a file on disk (the default) no locking across machines
azurerm a blob in an Azure Storage container locking with a blob lease
s3 an object in an S3 bucket locking with a lock file (1.10+)
gcs an object in a GCS bucket locking built in
consul, pg, http ... varies
cloud HCP Terraform / Terraform Enterprise runs, locking, history, policy
Words in that table:
- blob: Azure's word for one stored file inside a storage account.
- storage container: a named folder-like group of blobs inside a storage account. Nothing to do with Docker containers - same word, different thing.
- S3 bucket / GCS bucket: the same idea (a named place to store files) on Amazon's cloud (AWS) and Google's cloud.
- lease: a time-limited "I am using this" flag Azure puts on a blob; while one run holds it, another cannot take it. That is how azurerm locks.
The local backend is what you get with no block at all. You can still write it out, for example to keep state outside the code directory:
terraform {
backend "local" {
path = "../state/dev.tfstate"
}
}
The azurerm backend
This is the one the labs use: state as a blob in an Azure storage account.
terraform {
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "sttfstatesysop"
container_name = "tfstate"
key = "platform/dev.tfstate"
use_azuread_auth = true
}
}
Each argument:
resource_group_name the resource group the storage account lives in
storage_account_name the storage account (its name is unique across all of Azure)
container_name the storage container inside it
key the blob name - one per state; slashes make it look like a folder
use_azuread_auth log in to the blob as a person or pipeline identity (Entra ID),
not with the account's master key
Two ways Terraform can prove who it is to the storage account:
- Entra ID + a role. Entra ID is Azure's login system for people and programs. You give an identity a role on the container (a named set of permissions; giving it is a role assignment, and the whole scheme is called RBAC, role-based access control). The role needed here is Storage Blob Data Contributor (may read and write blobs).
- The account key (
ARM_ACCESS_KEYenvironment variable). One master password for the whole storage account: it grants everything and cannot be limited to one container. Avoid it.
On a laptop you sign in with the Azure CLI (az login, the az command-line tool). In a pipeline the job signs in with OIDC (the pipeline gets a short-lived token instead of a stored password). The backend uses the same login as the azurerm provider.
Locking is automatic: for every command that could write state, the backend takes a lease on the blob, and releases it when done.
The chicken and the egg
The storage account must exist before terraform init. A backend cannot create its own storage - it needs somewhere to keep the state of the thing that creates the storage.
Options, in order of preference:
- The platform team owns a small bootstrap configuration: a separate Terraform configuration (with local state, or state in its own "state of the states" account) that creates the storage accounts, containers, roles, versioning and soft delete once. 13.8 builds one.
- A script with the Azure CLI (
az storage account create ...), run once and kept in the repo.
Harden that storage account:
- blob versioning and soft delete (13.1), so a bad write or a delete can be recovered;
- no public network access, or a private endpoint (an address for the account inside your own private network, so it is not reachable from the internet);
- roles on the container only for the identities that need them;
- a resource lock (an Azure setting that blocks deleting the resource) so nobody deletes the account by accident.
No variables in a backend block
It is tempting to build the key from a variable:
terraform {
backend "azurerm" {
key = "platform/${var.env}.tfstate" # not allowed
}
}
╷
│ Error: Variables not allowed
│
│ on main.tf line 6, in terraform:
│ 6: key = var.state_key
│
│ Variables may not be used here.
╵
Why: the backend is set up before variables are evaluated. Terraform needs the state before it can evaluate anything else. The per-environment parts come from partial configuration instead.
Partial configuration: -backend-config
Leave out whatever differs per environment - or the whole body:
terraform {
backend "azurerm" {}
}
and hand the missing values to init with -backend-config. Either one key=value per flag, or a file:
terraform init -backend-config=backends/dev.hcl
terraform init \
-backend-config=resource_group_name=rg-tfstate \
-backend-config=storage_account_name=sttfstatesysop \
-backend-config=container_name=tfstate \
-backend-config=key=orders/dev.tfstate
The file holds plain key = value lines, no block around them:
# backends/dev.hcl
resource_group_name = "rg-tfstate"
storage_account_name = "sttfstatesysop"
container_name = "tfstate"
key = "orders/dev.tfstate"
Pipelines do exactly this: the same code, init with the environment's backend file, then plan. Secrets (an access key, if you really must) come in through environment variables, never in these files.
Changing the backend: init decides what happens to state
Change the backend block or its settings, and every other command refuses until you run init again:
╷
│ Error: Backend initialization required, please run "terraform init"
│
│ Reason: Initial configuration of the requested backend "azurerm"
│ ...
╵
Then init has to know what to do with the state you already have. The flags:
(no flag), local -> azurerm init offers to COPY the local state into the blob
- answer yes, or you start with an empty state
-migrate-state copy state from the old backend/key to the new one
-reconfigure use the new settings and IGNORE the old state entirely
-force-copy answer "yes" to the copy question without asking (for CI)
The copy prompt, word for word:
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend to the
newly configured "azurerm" backend. No existing state was found in the newly
configured "azurerm" backend. Do you want to copy this state to the new "azurerm"
backend? Enter "yes" to copy and "no" to start with an empty state.
Answering no is how teams end up with an empty remote state next to live infrastructure. The next plan thinks nothing exists and wants to create everything, and every create fails with already exists.
-reconfigure vs -migrate-state is the distinction to be precise about:
- Switching which state you point at (dev key -> prod key in a pipeline):
-reconfigure. You do not want dev's state copied over prod's. - Moving a state to a new home (new storage account, renamed key):
-migrate-state. The old blob keeps its copy until you delete it.
After a local -> remote move, the old terraform.tfstate is still on disk, secrets and all. Delete it once you have checked the remote copy.
What init recorded
init writes the backend settings it worked out into .terraform/terraform.tfstate. Despite the name this is not your state - just a small note of which backend to use:
# after init with the azurerm backend (the remote-state mission)
jq .backend .terraform/terraform.tfstate
{
"type": "azurerm",
"config": {
"container_name": "tfstate",
"key": "orders/dev.tfstate",
"resource_group_name": "rg-tfstate",
"storage_account_name": "sttfstatesysop"
},
"hash": "..."
}
type is the backend kind, config the resolved settings, hash a fingerprint Terraform uses to notice that the block changed.
That file is how a later terraform plan knows which blob to read - and why plan in a freshly cloned directory fails until you run init. It lives in .terraform/, so it is never committed.
Reading remote state
The state commands work against any backend, local or remote:
terraform state list # every address in the state
terraform state pull > /tmp/backup.tfstate # a local copy, e.g. before surgery
terraform output -json # the outputs, as JSON
In the lab, the remote blobs live in a simulated storage account. There is no az storage blob command, so terraform state pull is how you look inside (simulator).
Other backends, briefly (exam material)
The same idea on AWS:
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "platform/prod.tfstate"
region = "eu-west-1"
use_lockfile = true # 1.10+: locking with a lock file next to the state
encrypt = true
}
}
Older S3 setups locked through a separate database table (DynamoDB); that way is now deprecated.
HCP Terraform is HashiCorp's hosted service for running Terraform. It replaces the backend block with a cloud block (Terraform 1.1+):
terraform {
cloud {
organization = "acme"
workspaces {
name = "platform-prod"
}
}
}
With cloud, state lives in HCP Terraform, and terraform plan can run on HashiCorp's machines while streaming the output to you. You also get run history, locking, shared variables, policy checks and a private module registry. A workspace there is one state plus its settings.
Later (Ch 14): HCP Terraform in detail, for the exam.
Checklist for a real state backend
- One storage account per trust boundary (often one per environment), one container, one key per state.
use_azuread_auth = true; each pipeline identity has Blob Data Contributor on its own container only; people get read at most.- Versioning, soft delete, resource lock, no public access.
- Partial configuration files per environment in the repo; no secrets in them.
.gitignore:.terraform/,*.tfstate,*.tfstate.*,*.tfplan.
What you can now do:
- write an azurerm backend block and say what each argument is
- give per-environment backend values with
-backend-config - choose between
-migrate-stateand-reconfigure, and answer the copy prompt correctly