Skip to main content
RunBook Academy

TerraformVII · Resources, Data Sources, and count/for_eachResources

Resources: The Building Blocks

Foundation⏱ ~22 min🧪 Lab requiredbashterraform

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

Not yet marked complete on this device.

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. aws for the AWS provider, azurerm for 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-apply attribute.
  • 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:

  1. Destroys the old resource.
  2. Creates the new resource.

create_before_destroy = true reverses the order:

  1. Creates the new resource.
  2. 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_destroy does 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:

  • count over a list for stateful resources.
  • lifecycle.prevent_destroy = false on a database.
  • lifecycle.ignore_changes that hides real differences.
  • depends_on that 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

  1. Q1. What is a resource address?

  2. Q2. When does a resource trigger replacement vs in-place update?

  3. Q3. `count` provides stable resource identity.

  4. Q4. When is for_each preferred over count?

  5. Q5. Which of the following are data sources used for? (Select all that apply.)

  6. Q6. What is the role of `lifecycle.ignore_changes`?

  7. 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.