Table of contents
- Resources Are at the Center of Everything
- Resource Block Structure
- Resource References and Implicit Dependencies
- Explicit Dependencies — depends_on
- count — Replicate Resources by Number
- for_each — Iterate Over Maps or Sets
- count vs for_each at a Glance
- lifecycle — Fine-Grained Control Over Creation/Modification/Deletion
- Timeouts — How Long to Wait
- The Big Picture — Everything That Goes Into a Resource
- Common Mistakes
- Next Up
Resources Are at the Center of Everything
The bulk of Terraform code ultimately consists of resource blocks. Variables and providers are supporting actors that help you work with resources effectively, while resources themselves — the subject of this part — are the main character. This part may be a bit lengthy, but once you’ve digested it, you’ll be able to read and write production Terraform code on your own.
We’ll cover three main topics.
- The structure and reference syntax of
resourceblocks - Dependencies — how Terraform determines execution order
- Lifecycle — fine-grained controls like “recreate”, “prevent deletion”, and “ignore changes”
Resource Block Structure
Let’s start with the simplest form.
resource "<TYPE>" "<NAME>" {
# arguments
}
TYPE is the resource type defined by the provider. It follows a naming convention where the prefix reveals which provider it belongs to, like aws_instance, aws_s3_bucket, or kubernetes_deployment. NAME is the logical name you assign. It must be unique within a module and is used when referencing this resource from other resources.
Here’s a real example.
resource "aws_s3_bucket" "logs" {
bucket = "myapp-logs-prod"
tags = {
Environment = "prod"
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration {
status = "Enabled"
}
}
Both use the same NAME (logs), but since their TYPEs differ, there’s no conflict. When referencing from other resources, you use the <TYPE>.<NAME>.<ATTRIBUTE> format. aws_s3_bucket.logs.id is an example of this.
Three Kinds of Resource Properties
Each resource has three kinds of properties. Knowing this distinction makes reading documentation less confusing.
- Arguments: Values you define. e.g.,
bucket,tags - Attributes: Values AWS assigns after creation. e.g.,
id,arn - Meta-arguments: Special arguments common to all resources. e.g.,
count,for_each,depends_on,lifecycle,provider
Think of it as: arguments are “what I provide”, attributes are “what I get back”, and meta-arguments are “what Terraform interprets”.
Resource References and Implicit Dependencies
One of Terraform’s powerful features is that it automatically determines dependencies between resources. Even without explicitly stating dependencies, if resource A’s argument references an attribute of resource B, Terraform infers that “A must be created after B.” This is called an implicit dependency.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # References VPC
cidr_block = "10.0.1.0/24"
}
resource "aws_security_group" "web" {
vpc_id = aws_vpc.main.id # References VPC
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id # References subnet
vpc_security_group_ids = [aws_security_group.web.id] # References SG
}
From this code alone, Terraform draws a dependency graph like this.
flowchart TB
VPC[aws_vpc.main] --> SUBNET[aws_subnet.public]
VPC --> SG[aws_security_group.web]
SUBNET --> EC2[aws_instance.web]
SG --> EC2
DATA[data.aws_ami.amazon_linux] --> EC2
Execution order follows the topological sort of the graph. The VPC is created first, the subnet and security group are created in parallel, and the EC2 instance is created last. You didn’t specify any ordering, yet Terraform figured it out. This is the greatest advantage of the declarative approach.
You can also visualize the graph with the terraform graph command.
terraform graph | dot -Tpng > graph.png
This command is extremely helpful for debugging when resource relationships get tangled in large projects.
Explicit Dependencies — depends_on
In most cases, implicit dependencies are sufficient. But there are situations where “there’s no direct reference, yet ordering matters.” A classic example is when an IAM policy must be attached first so that an EC2 instance can access S3.
resource "aws_iam_role" "app" {
name = "app-role"
# ...
}
resource "aws_iam_role_policy_attachment" "s3_read" {
role = aws_iam_role.app.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.app.name
# EC2 only references the IAM Role
# But in practice, the policy must be attached first for it to work
depends_on = [
aws_iam_role_policy_attachment.s3_read
]
}
depends_on explicitly instructs “create this resource after those.” The value is a list of resource references (pointing to the resources themselves, not their attributes).
However, use depends_on only when truly necessary. Overusing it makes the graph complex and slows down plan execution. Prefer implicit dependencies when possible, and only use depends_on when there’s no other way.
count — Replicate Resources by Number
When you want to create multiple instances of the same resource, count is the first thing that comes to mind.
resource "aws_instance" "web" {
count = 3
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
tags = {
Name = "web-${count.index}" # web-0, web-1, web-2
}
}
count.index references the current iteration number. The created resources are accessed by index: aws_instance.web[0], aws_instance.web[1], etc.
# Reference a specific instance
output "first_instance_ip" {
value = aws_instance.web[0].public_ip
}
# Get all as a list (splat)
output "all_ips" {
value = aws_instance.web[*].public_ip
}
count is simple and easy to use, but it has one weakness. If you remove a middle element, the indices after it get reshuffled. For example, if you created resources with count = 3 and then try to remove web[1] by adjusting the code, Terraform won’t delete web[1] — it will modify web[1]‘s configuration and delete web[2] instead. From the user’s perspective, unexpected changes occur.
Because of this problem, for_each is better suited for “multiple resources where ordering doesn’t matter.”
for_each — Iterate Over Maps or Sets
for_each is more expressive than count. It takes a map or set and creates a resource for each key.
resource "aws_s3_bucket" "services" {
for_each = toset(["logs", "artifacts", "backups"])
bucket = "myapp-${each.key}-prod"
tags = {
Purpose = each.key
}
}
each.key is the current key being iterated, and each.value is the value (for sets, key == value). Resources are accessed by key.
output "artifacts_bucket_arn" {
value = aws_s3_bucket.services["artifacts"].arn
}
Using a map lets you give different configurations per key.
variable "services" {
default = {
logs = {
versioning = true
lifecycle = "log"
}
artifacts = {
versioning = true
lifecycle = "archive"
}
backups = {
versioning = false
lifecycle = "glacier"
}
}
}
resource "aws_s3_bucket" "services" {
for_each = var.services
bucket = "myapp-${each.key}-prod"
tags = {
Purpose = each.key
Lifecycle = each.value.lifecycle
}
}
resource "aws_s3_bucket_versioning" "services" {
for_each = { for k, v in var.services : k => v if v.versioning }
bucket = aws_s3_bucket.services[each.key].id
versioning_configuration {
status = "Enabled"
}
}
As long as the keys don’t change, Terraform manages the right resource in the right slot regardless of order changes. The practical rule of thumb is “use for_each by default for multiple resources, and count only for simple repetitions.”
count vs for_each at a Glance
flowchart LR
subgraph COUNT[count = 3]
C0["Instance 0"]
C1["Instance 1"]
C2["Instance 2"]
end
subgraph FOREACH[for_each = toset...]
F1["Instance logs"]
F2["Instance artifacts"]
F3["Instance backups"]
end
NUM[Numeric index<br/>Order matters] --> COUNT
KEY[String key<br/>Meaningful identifier] --> FOREACH
The general selection criteria are as follows.
- Multiple items that are exactly identical, where order or keys are meaningless →
count - Each item has a meaningful name or needs to be managed individually →
for_each
It’s better for the team to establish guidelines and use them consistently. Mixing count and for_each for the same type of resource leads to confusion later.
lifecycle — Fine-Grained Control Over Creation/Modification/Deletion
By default, Terraform only thinks about “making the current state match the desired state.” But in practice, there are nuanced requirements like “please create the new one before deleting the old one” or “this resource must never be accidentally deleted.” The lifecycle meta block handles these controls.
resource "aws_instance" "web" {
# ...
lifecycle {
create_before_destroy = true
prevent_destroy = false
ignore_changes = [tags["LastDeployed"]]
}
}
Four flags are key.
create_before_destroy
The default behavior is “if replacement is needed, destroy the existing resource and create a new one.” This causes downtime in between. create_before_destroy = true reverses the order: create the new resource first, update other resources to reference the new one, then destroy the old one.
resource "aws_launch_template" "web" {
name_prefix = "web-" # Prefix to avoid name collision
image_id = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
lifecycle {
create_before_destroy = true
}
}
This is especially useful for resources like Launch Templates where “the name must be unique and zero-downtime replacement is important.” Note that if names collide, it will fail. That’s why you use name_prefix so Terraform appends an auto-generated suffix.
sequenceDiagram
participant TF as Terraform
participant OLD as Old Resource
participant NEW as New Resource
Note over TF: Default (destroy before create)
TF->>OLD: Delete
TF->>NEW: Create
Note over NEW: Gap between deletion and creation
Note over TF: create_before_destroy = true
TF->>NEW: Create first
TF->>OLD: Delete after switching references
Note over NEW: Seamless transition
prevent_destroy
A safety mechanism for “resources that must never be deleted.” Use it on production RDS databases, S3 buckets with important data, Route53 hosted zones, etc.
resource "aws_db_instance" "main" {
# ...
lifecycle {
prevent_destroy = true
}
}
If Terraform tries to delete a resource with this flag enabled, it will fail with an error during the plan phase. To actually delete it, you must remove this line first and apply again. It’s the last line of defense against accidental terraform destroy.
ignore_changes
A flag that declares “treat this attribute as if Terraform doesn’t manage it.” Use it when an external system (auto-scaling, CI deployments) is changing the value and Terraform keeps detecting it as a “change.”
resource "aws_autoscaling_group" "web" {
name = "web-asg"
desired_capacity = 3
max_size = 10
min_size = 2
# ...
lifecycle {
# Auto-scaling frequently changes desired_capacity, so ignore it
ignore_changes = [desired_capacity]
}
}
resource "aws_instance" "web" {
# ...
tags = {
Name = "web"
LastDeploy = "2026-04-01" # CI updates this on every deployment
}
lifecycle {
# Only ignore the LastDeploy tag
ignore_changes = [tags["LastDeploy"]]
}
}
Using all makes Terraform “ignore all attribute changes for this resource.” Use this with caution. If applied too broadly, Terraform’s ability to manage state is weakened.
replace_triggered_by
Expresses the intent “replace this resource when another resource changes” (Terraform 1.2+).
resource "aws_instance" "web" {
# ...
lifecycle {
replace_triggered_by = [
aws_launch_template.web.latest_version
]
}
}
This automatically recreates the instance every time a new version of the Launch Template is released. Before this feature existed, you had to use the “tainted” concept for something similar, but this declaration is much clearer now.
Timeouts — How Long to Wait
There are cases where the default timeout is insufficient. A large RDS instance can take over 30 minutes to create. You can configure this per resource.
resource "aws_db_instance" "main" {
# ...
timeouts {
create = "60m"
update = "40m"
delete = "40m"
}
}
Supported actions vary by resource. Check the “Timeouts” section on each resource’s official documentation page.
The Big Picture — Everything That Goes Into a Resource
If we cram all the elements we’ve covered into a single resource, it looks like this.
resource "aws_instance" "web" {
# Basic arguments
ami = data.aws_ami.amazon_linux.id
instance_type = local.instance_type
subnet_id = aws_subnet.public.id
# Nested block
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
tags = merge(local.common_tags, {
Name = "web-${count.index}"
Role = "web"
})
# Meta-arguments
count = local.replicas
provider = aws.us_east_1 # Select a specific provider alias
depends_on = [aws_iam_role_policy_attachment.s3_read]
lifecycle {
create_before_destroy = true
ignore_changes = [tags["LastDeployed"]]
}
timeouts {
create = "10m"
}
}
It looks massive, but the composition principle is simple. Basic arguments and nested blocks are the resource’s content, while meta-arguments are instructions on how Terraform should handle this resource.
Common Mistakes
- Using
depends_onon a resource’s attribute.depends_onbelongs only in the meta-argument position of a resource block. It’s not allowed inside regular arguments - Using both
countandfor_eachon the same resource. A resource can only use one or the other - Passing a list directly to
for_each. It must be a map or set. If it’s a list, wrap it withtoset()or create a map using a for expression - Trying to remove code while
prevent_destroyis set. If you don’t remove this flag first, you’ll get a “prevent destroy” error - Overusing
ignore_changeswithall. Important changes go undetected, undermining the purpose of state management
Next Up
Now that we’ve covered resources, we’ve essentially seen most of Terraform’s core language. In the next part, we’ll look at the interface with existing infrastructure. We’ll cover how to query already-existing AWS resources (existing VPCs, AMIs, account information, etc.) using data blocks, and the process of bringing console-created legacy resources under Terraform management using terraform import.


Loading comments...