Resources
A resource is the most important element in the Terraform language. Every resource block describes exactly one infrastructure object — a virtual machine, a DNS record, an S3 bucket, an IAM policy. When you run Terraform, its single job is to make the real world match the set of resources you declared: creating what’s missing, updating what has changed, and destroying what you removed. Understanding resources is understanding Terraform itself.
The anatomy of a resource block
Every resource is declared with the resource keyword followed by two labels — the type and the name — and a body of arguments enclosed in braces.
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}
- Resource type (
aws_instance) — the kind of object to manage. The prefix (aws_) identifies the provider; the rest names the object. The provider’s schema decides which types exist and which arguments they accept. - Local name (
web) — your label for this resource within the module. It must be unique per type and is used to reference the resource elsewhere. It has no meaning to the cloud API. - Arguments — the key/value pairs that configure the object. Some are required, some optional, and many have provider-set defaults.
This syntax is identical in Terraform 1.5+ and OpenTofu — both consume the same HCL2 configuration and provider plugins, so resource blocks are fully portable between them.
Resource addresses
Terraform identifies each resource instance by a unique address. For a single resource the address is simply type.name:
aws_instance.web
When a resource is expanded with count, instances are addressed by integer index; with for_each, by map key. Addresses become the handle you use on the CLI to target or inspect a specific object.
resource "aws_instance" "worker" {
count = 3
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
}
| Construct | Example address | Refers to |
|---|---|---|
| Single resource | aws_instance.web | The one instance |
count | aws_instance.worker[0] | First of the indexed instances |
for_each | aws_instance.worker["api"] | Instance keyed "api" |
| In a module | module.network.aws_vpc.main | A resource inside a child module |
You can inspect any address directly from state:
terraform state show aws_instance.web
Referencing attributes between resources
Resources rarely stand alone. You wire them together by referencing one resource’s exported attributes from another’s arguments, using type.name.attribute. These references are how Terraform infers dependencies and the correct order of operations.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
}
Because aws_subnet.public reads aws_vpc.main.id, Terraform knows the VPC must be created before the subnet. You almost never write explicit ordering — the references express it for you.
Tip: Prefer attribute references over hard-coded IDs.
aws_vpc.main.idkeeps your config correct even when the underlying resource is recreated and its real ID changes.
How Terraform manages a resource’s lifecycle
For every resource, Terraform compares three things: your configuration, the recorded state, and the real infrastructure. From that comparison it decides on one of four actions.
| Symbol | Action | When it happens |
|---|---|---|
+ | Create | Resource is in config but not in state |
~ | Update in place | An argument changed and the provider can patch it |
-/+ | Replace | A changed argument forces destruction and recreation |
- | Destroy | Resource removed from config but still in state |
Running a plan shows exactly what Terraform intends to do:
terraform plan
Output:
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c55b159cbfafe1f0"
+ instance_type = "t3.micro"
+ id = (known after apply)
+ private_ip = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Values marked (known after apply) are attributes the provider only learns once the object actually exists. Applying the plan reconciles reality with your config and writes the result to state:
terraform apply
Output:
aws_instance.web: Creating...
aws_instance.web: Creation complete after 32s [id=i-0abc123def4567890]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Controlling lifecycle behaviour
The nested lifecycle block lets you override Terraform’s default create/replace logic per resource — useful for protecting stateful objects or avoiding downtime.
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
lifecycle {
create_before_destroy = true
prevent_destroy = true
ignore_changes = [tags["LastScanned"]]
}
}
create_before_destroy— provision the replacement before tearing down the old object (zero-downtime replacement).prevent_destroy— make Terraform refuse any plan that would delete this resource.ignore_changes— treat the listed attributes as managed outside Terraform, suppressing drift on them.
Best practices
- Give resources clear, role-based local names (
web,primary_db) rather than encoding the provider or type, which is already in the labels. - Reference attributes (
aws_vpc.main.id) instead of hard-coding IDs so dependencies and recreations stay correct automatically. - Use
for_eachovercountwhen managing a set of similar resources — map keys keep addresses stable when the collection changes. - Reach for
lifecycleblocks sparingly and deliberately;prevent_destroyon databases andcreate_before_destroyfor stateless compute are the common, justified cases. - Always review the plan output and the
+ / ~ / -/+ / -symbols before applying — a-/+replacement on a stateful resource can mean data loss. - Keep every managed object in configuration; resources created by hand outside Terraform won’t appear in state and will drift from your declared intent.