Skip to content
ioob.dev
Go back

Terraform Part 6 — Resources and Dependencies

· 7 min read
Terraform Series (6/15)
  1. Terraform Part 1 — What Is Terraform
  2. Terraform Part 2 — Installation and First Deploy
  3. Terraform Part 3 — HCL Syntax
  4. Terraform Part 4 — Variables and Outputs
  5. Terraform Part 5 — Providers
  6. Terraform Part 6 — Resources and Dependencies
  7. Terraform Part 7 — Data Sources and Import
  8. Terraform Part 8 — State Management
  9. Terraform Part 9 — Modules
  10. Terraform Part 10 — Loops and Conditionals
  11. Terraform Part 11 — Workspaces and Environment Separation
  12. Terraform Part 12 — Kubernetes and Helm Providers
  13. Terraform Part 13 — CI/CD Integration
  14. Terraform Part 14 — Testing and Policy
  15. Terraform Part 15 — Practical Patterns and Pitfalls
Table of contents

Table of contents

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.

  1. The structure and reference syntax of resource blocks
  2. Dependencies — how Terraform determines execution order
  3. 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.

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.

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

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.


Part 7: Data Sources and Import


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform Part 5 — Providers
Next Post
Terraform Part 7 — Data Sources and Import