Two Labels, One Address: What "aws_instance.web" Really Means
Every managed object in Terraform begins as a resource block, and its two labels do two entirely different jobs:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Name = "web-01"
}
}
The second label — "aws_instance" — is not yours to choose. It is the provider's resource type, the name the AWS API and the provider schema understand; every argument in the body must exist in that schema, and any argument that does not fails validation with "Unsupported argument". The provider's documentation is therefore a vocabulary list, and the block is a sentence you construct from it. Read terraform state show aws_instance.web after an apply and you are reading exactly what the schema plus your arguments produced — the authoritative record of the object's attributes.
The first label — "web" — is entirely yours. It exists only inside this directory: it names the object for references (aws_instance.web.id), for state addressing, and for plan output. "web", "app_primary", "bastion_eu1" are all equally valid, and the local name carries no hint of the cloud-side name unless you make it do so via the name argument or tags. That freedom comes with two responsibilities. First, the pair must be unique across the whole directory — a second resource "aws_instance" "web" fails with a duplicate-resource configuration error, since the address aws_instance.web must resolve to exactly one object in state. Second, the name becomes permanent the moment you apply, because the address is what state records. Renaming the local name is not an edit; it is a destroy-and-create that plan output shows as -/+ must be replaced — the same event as changing a ForceNew argument, and equally service-affecting. The safe rename uses state migration, and the exact procedure — terraform state mv 'aws_instance.web' 'aws_instance.app' — is worth practicing on a practice stack before you need it in anger on a production one.
Two smaller details finish the anatomy. Arguments with simple values (ami = "...") are attributes; arguments that repeat as nested blocks (ingress { ... } inside a security group) follow the block rules from Block Structure and Nesting. And every computed value — IDs, ARNs, DNS names — is exposed by the resource after creation and consumed elsewhere by reference; you never type an ID that Terraform could compute, because hardcoded values are exactly the snowflake-seeded habit this whole topic exists to eliminate.
Two block types look like resources and are not, and telling them apart is half of reading any configuration. A data block — same two labels, same body of schema-checked arguments — reads rather than manages; it will be the next topic in depth, but the visual symmetry matters now because references distinguish them by scope: aws_instance.web.id versus data.aws_ami.ubuntu.id. And the moved block — single-label, no body of arguments — expresses a state rename in configuration rather than through a CLI command, which is how teams perform the rename ritual from the next chapter safely in code review. Everything else in a resource body that Terraform recognizes before the provider sees it — count, for_each, depends_on, lifecycle, provider — is a meta-argument, and you already know its vocabulary from the expressions phase. With the labels' jobs separated, the natural next question is which cloud configuration the block talks to, which is a provider-association question.
The provider Meta-argument: One Directory, Many Regions and Accounts
A resource block does not carry credentials or a region; those live in provider blocks, and the linkage between the two is an association Terraform resolves by rule. With one provider "aws" configured, every aws_* resource in the directory uses it — the default provider configuration. The moment you need more than one — a second region, a second account — you declare additional configurations with alias and bind resources explicitly:
provider "aws" { region = "eu-west-1" }
provider "aws" {
region = "us-east-1"
alias = "us"
}
resource "aws_s3_bucket" "logs" {
provider = aws.us
bucket = "acme-logs-us"
}
The provider meta-argument is consumed by Terraform itself — it never reaches the AWS API — and its value is the provider configuration reference: the type plus the alias, no quotes. The association decides which credentials, region, and default tags apply to this object, and it is recorded so that later plans apply the same identity rules consistently. Get it wrong and the errors are satisfyingly early: "Provider configuration alias "us" is not declared" catches typos and deleted aliases at validate time rather than after a resource lands in the wrong region.
The rules of association, in order: if the block names a configuration, that one is used; otherwise Terraform falls back to the default configuration of the provider type; if there is no default (only aliased configurations exist), the resource fails to resolve a provider entirely. Misconfiguration fails early and specifically: assigning an aliased resource to a nonexistent alias errors at validate time with the alias named, and a directory with only aliased provider blocks produces "Provider configuration ... not declared" style errors on unassociated resources — both messages worth having seen once before an incident requires it. Default tags deserve a mention here because they interact with associations in plans: default_tags on an AWS provider configuration merge into every resource's tags, and a resource that overrides one of those keys produces a diff showing the merged value — a frequent source of "where did this tag come from?" questions answered by reading the right provider block.
A subtlety that trips up module authors: provider configurations are not inherited the way variables are. A child module cannot see the parent's aliases by existing — the parent must pass them through the module call's own providers argument:
module "dr_replica" {
source = "./modules/replica"
providers = {
aws = aws.us # hand the aliased configuration to the child
}
}
Inside the child, resources reference the module's own provider "aws" (usually an empty block that the mapping fills in). This explicit wiring exists because provider configuration carries credentials — Terraform refuses to let them flow implicitly through the module tree, and that refusal is a security design, not an inconvenience. Two account-wide patterns fall straight out of this machinery: per-account stacks with separate states (the common, simpler choice), and single-directory multi-account resources via aliased providers with assume_role (convenient, but one state blast radius spanning accounts). Both appear in real teams; knowing why the explicit provider passing exists is what lets you reason about which to pick when an interviewer asks.
Addresses Are Contracts: Indexes, Keys, and the state mv Ritual
An object's address — aws_instance.web — is the contract between configuration and state, and once applied it is load-bearing in three places at once: references in configuration, entries in terraform state, and names in plan output. Learning to move fluently among those views is the practical skill this topic exists to build:
terraform state list # every address Terraform manages
terraform state show aws_instance.web # full recorded attributes for one object
terraform state mv 'aws_subnet.private[0]' 'aws_subnet.private["blue"]'
The indexed and keyed addresses are where reality gets interesting. A resource created with count = 3 occupies type.name[0], [1], [2]; one created with for_each occupies type.name["key"] per map key or set element. The indexes are positions in an ordered list, so removing element 0 shifts every higher index — instance [2] becomes [1] and plans a cascade of replacements and reattachments that looks catastrophic in review. Keys are stable identities, which is the deep reason for_each wins whenever instances have meaningful names. terraform state mv is how you retarget an existing object to a new address without touching the cloud: the object is untouched; only Terraform's memory changes. Quoting matters in practice — shell quoting, specifically: state mv aws_instance.web aws_instance.app without quotes can be mangled by glob expansion, so single-quote both addresses and move one object at a time on anything important. The moment before any rename, terraform state list and a dry-run terraform plan are non-negotiable — state surgery without inspection is how real infrastructure gets destroyed by someone who thought they were tidying up.
Addressing rules generalize cleanly. aws_ebs_volume.data[1] addresses one element of a counted resource; aws_sns_topic.events["orders"] one element of a for_each resource; module.network.aws_subnet.private[0] descends into a child module's resources. Everything addressable also appears in terraform state list, so the state file — never hand-edited, always queried through these commands — remains the single source of truth about what exists. Treat those commands as read-only by default: state mv, state rm, and import are the three mutations, each with a dry-run habit attached (list, plan, then act), because a state mistake does not fail loudly like a bad apply — it rewrites Terraform's memory of reality, and the consequences surface on the next plan.
Modern Terraform gives the rename ritual a configuration-native form worth knowing: the moved block. It expresses the same operation as state mv, but as reviewed code that runs during the next plan and then retires:
moved {
from = aws_instance.web
to = aws_instance.app
}
The plan narrates the move ("Move notice: aws_instance.web → aws_instance.app") without touching the cloud object, and teammates get the migration through a normal pull request instead of a shell command someone had to remember. moved blocks accumulate in the configuration as a permanent record of renames — the refactor history your state went through, readable by anyone. And when an object's reality and state disagree because someone created the real thing by hand, the inverse operation attaches it: terraform import aws_s3_bucket.legacy my-existing-bucket binds an existing object to an address so future plans manage it — the subject of dedicated practice later in this phase.
With resources declared, provider-bound, and addressable, the interface layer is next: publishing values other people and pipelines consume in Output Value Declarations.
Quiz
1. You change `resource "aws_instance" "web"` to `resource "aws_instance" "app"` and run terraform plan. What does the plan show, and how do you avoid it?
2. What does the second label of a resource block represent?
3. A child module contains aws_* resources and the parent has provider "aws" { alias = "us" }. How do the child's resources use the aliased configuration?
4. A counted resource (count = 3) has element [2] that must survive removing element [0]. Why is that dangerous, and what is the remedy?
Flashcards
Question
What are the two labels of a resource block and what does each control?
Click to reveal answer
Answer
The first is your local name — used for references (`aws_instance.web.id`), state addressing, and plan output; unique per type in the directory and permanent after apply without a state mv. The second is the provider's resource type (`aws_instance`) — fixed by the provider schema, determining which arguments the body may contain and how the API sees the object.
Question
Why is renaming a resource's local name a destructive operation, and what is the fix?
Click to reveal answer
Answer
The local name is half of the state address; changing it makes Terraform see one unmanaged new object and one orphaned old one, so the plan destroys and recreates. Fix: `terraform state mv 'type.old' 'type.new'` before applying, which retargets state without touching the cloud object.
Question
What is the provider meta-argument, and in what order are provider configurations associated?
Click to reveal answer
Answer
`provider = aws.us` (an undeclared-name reference, no quotes) binds a resource to an aliased provider configuration; Terraform uses it to decide credentials, region, and defaults — it never reaches the API. Resolution order: explicit `provider` reference → the provider type's default configuration → error if no default exists.
Question
Why must provider configurations be passed to child modules explicitly?
Click to reveal answer
Answer
Providers carry credentials, so Terraform refuses implicit inheritance; the parent declares `providers = { aws = aws.us }` on the module call, matched to a provider block in the child. This makes credential scope visible in review and prevents a module from silently using the wrong account or region.
Question
How do you address and inspect objects in state, including for_each elements?
Click to reveal answer
Answer
`terraform state list` (all addresses), `terraform state show <addr>` (one object's attributes), with indexed (`aws_ebs_volume.data[1]`) and keyed (`aws_sns_topic.events["orders"]`) forms, and module prefixes (`module.network.aws_subnet.private[0]`). `terraform state mv` changes an address without touching reality; `terraform import` attaches an existing object to an address.
Revision Notes
Key Takeaways
- 1. The second label is the provider's schema type and is not negotiable; the first label is your local name, forming the permanent state address — renaming it is a replace unless you `state mv` first
- 2. Arguments are validated against the provider schema at plan time; computed attributes (IDs, ARNs) are referenced, never hardcoded
- 3. `provider = aws.us` binds a resource to an aliased configuration (credentials, region, defaults); the default configuration applies when no binding is present
- 4. Provider configurations are passed to child modules explicitly through `providers = {...}` because they carry credentials — a security boundary, not an inconvenience
- 5. Addresses come in plain, indexed, and keyed forms and are queryable with `terraform state list/show`; state commands change Terraform's memory, the cloud only changes through apply
Interview Tips
- • "Annotate this resource block." — block type `resource`, provider type label `aws_instance`, local name `web` forming the address `aws_instance.web`, then schema arguments; add that the address is permanent after apply and lives in state
- • "How do you manage resources in two regions from one configuration?" — aliased provider blocks plus `provider = aws.<alias>` on resources; add the module caveat: providers must be passed explicitly via `providers = {}` because credentials do not inherit
- • "You need to rename a resource. Walk me through it safely." — `terraform state mv 'old' 'new'`, then plan to confirm zero changes; explain that without the mv the plan shows a destructive replace because the address is the state identity
- • "count versus for_each for instance identity." — count indexes shift when an element is removed (cascade replaces); for_each keys are stable identities — this is a state-addressing argument, not a style preference
Cheat Sheet
Resource Blocks
Anatomy:
resource "aws_instance" "web" { # provider type (schema) + your local name (address)
ami = data.aws_ami.ubuntu.id # argument: must exist in the schema
instance_type = "t3.micro"
provider = aws.us # meta-argument: binds an aliased configuration
}
Providers in one directory:
provider "aws" { region = "eu-west-1" } # default
provider "aws" { region = "us-east-1", alias = "us" }
module "replica" {
source = "./modules/replica"
providers = { aws = aws.us } # explicit: credentials never inherit implicitly
}
State addressing:
| Form | Example |
|---|---|
| plain | aws_instance.web |
| count index | aws_ebs_volume.data[1] |
| for_each key | aws_sns_topic.events["orders"] |
| inside module | module.network.aws_subnet.private[0] |
Rename safely: terraform state mv 'type.old' 'type.new' — changes Terraform's memory, never the object. Duplicate type+name pairs and undeclared provider aliases fail at validate time.