TerraformVII · Resources, Data Sources, and count/for_eachResources
Resources: The Building Blocks
What you'll learn
- Write a resource block for any provider
- Read and use resource attributes
- Use `count` and `for_each` for multiple instances
- Apply lifecycle meta-arguments: create_before_destroy, prevent_destroy, ignore_changes
Prerequisites
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-12
A resource block declares a resource that Terraform should
manage. The providers documentation is the authority for what
arguments each resource accepts. This lesson teaches the
structural rules every resource follows, the meta-arguments that
control resource behaviour, and the production traps.
The basic resource block
resource "<provider>_<type>" "<name>" {
# arguments -- depend on the resource type
# meta-arguments -- the same across all resource types
}
The three parts:
- Provider type — the prefix on the resource type (e.g.
awsfor the AWS provider,azurermfor the Azure provider). - Resource type — the kind of resource (e.g.
instance,s3_bucket). - Resource name — the local name in the configuration. Must be unique within the working directory.
A resource is referred to by its full address:
<provider>_<type>.<name>. For example, aws_instance.web.
Resource arguments
The arguments are provider-specific. The provider documentation is the authority. A few rules:
- Required arguments are flagged as such in the documentation.
- Optional arguments have defaults that you can override.
- Blocks (nested objects) are documented separately from arguments.
The Terraform documentation at developer.hashicorp.com is the
authoritative source. Each providers documentation is hosted on
the same site. The providers terraform-provider- repository on
GitHub is the source of truth.
Resource attributes
After a resource is created, the provider returns attributes. The attributes are accessible in the configuration via references:
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
}
output "instance_id" {
value = aws_instance.web.id # the EC2 instance ID
}
output "private_ip" {
value = aws_instance.web.private_ip # the EC2 private IP
}
output "public_dns" {
value = aws_instance.web.public_dns # the EC2 public DNS
}
The attribute names are provider-specific. The provider documentation lists the attributes.
Some attributes are computed only after apply (the
known-after-apply values from the plan lesson):
output "public_ip" {
value = aws_instance.web.public_ip
# at plan time, this is (known after apply)
# at apply time, the provider returns the real value
}
A reference to a known-after-apply attribute is undefined at
plan time. The downstream resource must use depends_on if it
depends on the attribute.
Meta-arguments
Terraform has five meta-arguments that are valid inside every resource block:
resource "aws_instance" "web" {
# arguments (provider-specific)
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
# meta-arguments (always valid)
count = 3
for_each = var.subnets
depends_on = [aws_security_group.web]
provider = aws.west
lifecycle {
create_before_destroy = true
prevent_destroy = false
ignore_changes = [tags["LastReviewed"]]
}
}
The next sections cover count, for_each, depends_on, and
lifecycle in detail. provider is covered in the aliases
lesson.
count: indexed instances
count creates N instances of a resource:
resource "aws_instance" "web" {
count = 3
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
}
The result is aws_instance.web[0], aws_instance.web[1],
aws_instance.web[2].
The trap: count is identity-fragile. If the count changes
from 3 to 5, the resource at index 3 is a new resource, but the
resources at indices 0, 1, 2 are the same. If the count changes
from 3 to 2, the resource at index 2 is destroyed. The state does
not care about the semantic meaning of the indices; it only cares
about the count.
count = 3 count = 5 count = 2
web[0] web[0] web[0]
web[1] web[1] web[1]
web[2] web[2] -- DESTROYED --
web[3] NEW
web[4] NEW
This is fine for ephemeral resources (e.g. “create 3 worker nodes”). It is dangerous for stateful resources (e.g. “create 3 database replicas”) — adding a fourth replica should not re-create the existing three.
for_each: keyed instances
for_each creates one instance per element of a set or map:
variable "subnets" {
type = map(object({
cidr = string
}))
default = {
"a" = { cidr = "10.0.1.0/24" }
"b" = { cidr = "10.0.2.0/24" }
}
}
resource "aws_subnet" "public" {
for_each = var.subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = "${var.region}${each.key}"
}
The result is aws_subnet.public["a"] and
aws_subnet.public["b"]. The keys are stable: removing a key
destroys only that resource, not the others.
The advantages of for_each over count:
- Identity is stable. Removing “b” does not affect “a”.
- The keys are meaningful. The state and plan read more clearly.
- The errors are isolated. A failure on one resource does not cascade.
The rule: use for_each whenever the resource has a stable
semantic key. Use count only when the resources are
truly interchangeable (e.g. a fixed-size worker pool).
depends_on: explicit dependencies
depends_on declares an explicit dependency. The resource will
not be created until the named resources have completed.
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
depends_on = [
aws_iam_instance_profile.web,
]
}
Use depends_on when:
- The dependency is on a side effect of another resource (e.g. an IAM role that the instance implicitly uses).
- The reference is to a
known-after-applyattribute. - The implicit dependency is not yet strong enough for the providers semantics.
Avoid depends_on when:
- The dependency is already implicit (a reference to the resources attribute).
- The dependency is to a resource that is unrelated to the current resource.
A configuration that relies heavily on depends_on is a
signal that the model is wrong. The course has a dedicated
lesson on this.
Lifecycle: create_before_destroy
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
lifecycle {
create_before_destroy = true
}
}
By default, when a resource must be replaced, Terraform:
- Destroys the old resource.
- Creates the new resource.
create_before_destroy = true reverses the order:
- Creates the new resource.
- Destroys the old resource.
The reversal is appropriate when:
- The resource is part of a multi-resource cluster and the cluster can survive short-term over-provisioning.
- The new resource must be validated before the old resource is destroyed.
- The destruction would cause downtime that the new resource can avoid.
The reversal is not a substitute for a careful plan. A replace is a replace. The course returns to this in Part CXXXIV.
Lifecycle: prevent_destroy
resource "aws_db_instance" "primary" {
# ...
lifecycle {
prevent_destroy = true
}
}
prevent_destroy = true is a circuit breaker. The plan fails
if the configuration would destroy the resource.
The circuit breaker does not prevent:
- Manual destruction of the real-world resource.
- State manipulation that removes the resource from state.
- Resource replacement (the resource is destroyed and recreated,
which
prevent_destroydoes NOT prevent).
The course has a dedicated lesson on protection patterns for stateful resources.
Lifecycle: ignore_changes
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
tags = {
Name = "web-01"
}
lifecycle {
ignore_changes = [
tags["LastReviewed"],
user_data,
]
}
}
ignore_changes tells Terraform to ignore specific attributes
when computing the plan. The attribute is read from the state,
not from the configuration.
The legitimate use cases:
- An attribute is managed outside Terraform (e.g. a tag added by a third-party monitoring tool).
- An attribute is auto-computed by the provider (e.g. an AWS attribute that the provider returns but does not accept as input).
- A computed attribute that the operator expects to drift.
The illegitimate use cases:
- Hiding a configuration error. If the configuration has a bug, fix the configuration.
- Disabling plan review. The plan is the only safety net.
- Workaround for a provider bug. File the bug.
Lifecycle: replace_triggered_by
replace_triggered_by is a more precise version of the legacy
taint mechanism. It allows a resource to be replaced when
another resources attribute changes:
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
lifecycle {
replace_triggered_by = [
aws_ami.ubuntu.id,
]
}
}
When aws_ami.ubuntu.id changes, the aws_instance.web is
replaced during the next apply. The replacement is explicit; the
plan shows it.
Production trap summary
A few patterns to flag in review:
countover a list for stateful resources.lifecycle.prevent_destroy = falseon a database.lifecycle.ignore_changesthat hides real differences.depends_onthat hides an implicit dependency.- Resource blocks that always replace on apply (the plan shows this immediately).
What comes next
The next lesson is data sources — the resource-like construct for reading infrastructure that Terraform does not manage.
Knowledge check · 7 questions
Q1. What is a resource address?
Q2. When does a resource trigger replacement vs in-place update?
Q3. `count` provides stable resource identity.
Q4. When is for_each preferred over count?
Q5. Which of the following are data sources used for? (Select all that apply.)
Q6. What is the role of `lifecycle.ignore_changes`?
Q7. A resource shows a +20 to change in the plan for an attribute that should be stable. What is the most likely cause?
Passing score: 75%. Answers are checked in this browser.