Skip to main content
RunBook Academy

TerraformIX · State: The Core Production ConceptProduction Terraform

Resource Addresses and Real-World Mapping

Intermediate⏱ ~12 minbash

What you'll learn

  • Read and construct a Terraform resource address
  • Trace a configuration resource to its state entry and to its real-world ID
  • Recognise when a refactor has changed the address and the plan will destroy-and-create
  • Apply the discipline that prevents accidental address changes

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

A resource address is the single string Terraform uses to refer to a resource in plan output, in state, in CLI commands, and in cross-module references. Most production incidents caused by “Terraform wants to destroy and recreate the database” trace back to a change in this string. Understanding the address syntax is the first step to preventing those incidents.

The address syntax

A resource address has up to four components:

module.<module_name>[.<nested_module>].<resource_type>.<resource_name>[<instance_key>]

In the root module:

aws_instance.web
google_compute_instance.app
azurerm_resource_group.main
data.aws_ami.ubuntu

Inside a module:

module.network.aws_vpc.main
module.network.aws_subnet.public[0]
module.network.aws_subnet.public["us-east-1a"]

With count:

aws_instance.web[0]
aws_instance.web[1]

With for_each:

aws_instance.web["api"]
aws_instance.web["worker"]

The instance_key is required when the resource uses count or for_each. It is omitted for the single-instance case.

From configuration to address

The address is derived mechanically from the configuration:

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"
}

This produces the address aws_instance.web in the root module.

resource "aws_subnet" "public" {
  count = 3
  vpc_id = aws_vpc.main.id
  cidr_block = "10.0.${count.index}.0/24"
}

This produces three addresses: aws_subnet.public[0], aws_subnet.public[1], aws_subnet.public[2]. The count.index is the instance key.

resource "aws_route_table" "rt" {
  for_each = toset(["us-east-1a", "us-east-1b", "us-east-1c"])
  vpc_id = aws_vpc.main.id
}

This produces aws_route_table.rt["us-east-1a"] and so on. The for_each key is the instance key.

From address to state entry

Each address has exactly one entry in the state resources array. The entry’s keys mirror the address components:

{
  "mode": "managed",
  "type": "aws_instance",
  "name": "web",
  "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
  "instances": [
    {
      "attributes": {
        "id": "i-0a1b2c3d4e5f6a7b8"
      }
    }
  ]
}

type and name come straight from the resource block. mode is managed for resources and data for data sources. module is empty for the root module and populated for nested modules.

For a resource with count or for_each, each instance is a separate entry in the instances array, identified by index_key:

{
  "mode": "managed",
  "type": "aws_subnet",
  "name": "public",
  "instances": [
    { "index_key": 0, "attributes": { "id": "subnet-0aaa" } },
    { "index_key": 1, "attributes": { "id": "subnet-0bbb" } },
    { "index_key": 2, "attributes": { "id": "subnet-0ccc" } }
  ]
}

From state entry to real-world object

Inside each instance, attributes.id is the real-world identifier returned by the provider API. For an aws_instance, that is the EC2 instance ID. For an aws_vpc, that is the VPC ID. For a google_compute_instance, that is the instance self-link.

The address-to-ID mapping is what state records. Without state, Terraform has no way to associate aws_instance.web with i-0a1b2c3d4e5f6a7b8. With state, the mapping is one JSON lookup.

Configuration:  aws_instance.web
            │
            ▼
State:         { "type": "aws_instance", "name": "web",
            │     "instances": [ { "attributes": { "id": "i-0abc…" } } ] }
            ▼
Real world:    EC2 instance i-0a1b2c3d4e5f6a7b8

When addresses change — the failure mode

A change in any of the four address components — module, type, name, or instance_key — produces a different address. From Terraform’s perspective, the old resource is gone and a new one is needed:

Resource block: aws_instance "web"  →  address aws_instance.web
Resource block: aws_instance "app"  →  address aws_instance.app

Renaming web to app in the configuration changes the address. The next plan will read the state entry for aws_instance.web, notice no configuration describes that address, and propose to destroy the real-world object. It will then read the new configuration for aws_instance.app, notice no state entry, and propose to create it.

The fix is one of two operations covered later in this part:

  • A moved block — declarative, in code, reviewable.
  • terraform state mv — imperative, in the CLI, logged.

Either approach tells Terraform that the real-world object has moved from one address to another. Without one of them, Terraform treats the rename as a destroy-and-create.

Discipline: keep addresses stable

Three rules prevent address-driven incidents:

1. Never rename a resource without a moved block. If a configuration refactor changes the resource name, add a moved block in the same change.

2. Never change a count to a for_each (or vice versa) without a moved block per instance. The instance keys differ even when the resource name is unchanged.

3. Never move a resource between modules without a moved block. A refactor that moves a resource from the root module into module.network changes the address and requires explicit movement.

Validation

READ-ONLY

terraform state list

Output:

aws_instance.web
aws_vpc.main
aws_subnet.public[0]
aws_subnet.public[1]
aws_subnet.public[2]
module.network.aws_route_table.rt["us-east-1a"]
module.network.aws_route_table.rt["us-east-1b"]
module.network.aws_route_table.rt["us-east-1c"]

Every line is a complete resource address. Confirm that:

  • The count of each address matches the count of resource blocks in configuration.
  • The for_each keys match the input set.
  • The module paths match the directory structure.
  • No addresses appear that are not in configuration (those are orphans; covered in the pitfalls lesson).
  • No addresses in configuration are missing from the list (those would cause plan-time errors).

For one address:

terraform state show 'aws_subnet.public[0]'

Output (illustrative):

# aws_subnet.public[0]:
resource "aws_subnet" "public" {
    id             = "subnet-0aaa"
    vpc_id         = "vpc-0123456789abcdef0"
    cidr_block     = "10.0.0.0/24"
    ...
}

The id here is the real-world ID from the provider API. The rest of the attributes are the last-known values.

Production failure modes

Symptom: plan proposes to destroy aws_instance.web and create aws_instance.app. Cause: a rename in configuration without a moved block or state mv. Recovery is to add the moved block, run plan, confirm “no changes”, then apply.

Symptom: plan proposes to recreate every instance of a count resource after adding a new item. Cause: count.index is not stable when items are inserted into the middle of a list. The instances shift. Recovery is to migrate to for_each with stable keys (instance IDs, names) before the change.

Symptom: state has entries for resources that no longer exist in configuration. Cause: a previous refactor removed the resource block without removing it from state. The next apply will propose to destroy the real-world object. Recovery is terraform state rm to detach the state entry without touching the real world.

Symptom: plan shows the same real-world object twice. Cause: the same resource has been imported twice under different addresses, or two configuration blocks both reference the same real-world object. Recovery is to remove the duplicate import and verify the plan.

Symptom: terraform state mv errors with “cannot move between modules”. Cause: the operation requires that the source and destination modules exist in configuration. The fix is to add the destination resource block first, then run state mv.

Recovery

  1. Identify the address that has changed: terraform state list (read-only) shows the current addresses in state.
  2. Compare to the addresses implied by the current configuration.
  3. Add a moved block to tell Terraform that the resource has moved from the old address to the new one. Re-plan; expect “no changes”.
  4. Apply the moved block alone, in a separate commit, before making the underlying refactor.
  5. Verify with terraform plan (read-only) afterwards.

What comes next

The next lesson covers the configuration-state-reality model: the three sources of truth that Terraform reconciles, and what happens when they disagree.

Verification

  • You can construct a resource address from a configuration block, including module paths and instance keys.
  • You can read a state entry and identify the real-world ID it points at.
  • You can spot a refactor that changes an address and identify the move-block or state-mv operation needed to prevent destroy.
  • You can run terraform state list and confirm every address in state corresponds to a resource block in configuration.

Knowledge check · 7 questions

  1. Q1. Which of the following is the correct address for the third instance of an aws_subnet.public with count = 3?

  2. Q2. What does the attributes.id field inside a state instance record?

  3. Q3. Renaming a resource block from aws_instance.web to aws_instance.app without any other change is, by default, treated by Terraform as a destroy-and-create.

  4. Q4. Which field inside a state resource entry records the module path?

  5. Q5. Which of these operations change a resource address? (Select all that apply.)

  6. Q6. When a for_each is used with a toset of strings, what does the instance key look like in the address?

  7. Q7. You refactor a module and move the aws_db_instance.primary resource from the root module into module.database. Plan proposes to destroy the database. What is the correct fix?

Passing score: 75%. Answers are checked in this browser.