Skip to main content

Terraform Resources, Data Sources and Meta-Arguments

Create Terraform resources, read data sources, set dependencies, and use count, for_each and lifecycle rules such as prevent_destroy and ignore_changes.

TLDR: A resource block creates and manages one kind of object; a data block reads an object that exists already. References between blocks set the order, and depends_on adds a dependency Terraform cannot see. Meta-arguments work on every resource: count and for_each create several copies, provider picks a provider configuration, and lifecycle changes how Terraform replaces or deletes the object (create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by, preconditions and postconditions).

Resources are what you spend most of your time writing. This part builds a small web server on AWS to show how resources and data sources fit together, then runs the meta-arguments for real on a local project, so you can see exactly what the plan and apply output look like.

Prerequisites

  • Terraform installed (Part 2) and the HCL basics from Part 3.
  • For the AWS example: an AWS account and credentials. Everything else runs locally.

Providers and Resource Types

Every resource type belongs to a provider. You declare the provider in required_providers and configure it with a provider block:

terraform {
  required_version = ">= 1.17"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.70"
    }
  }
}

provider "aws" {
  region = var.region

  # Added to every resource that supports tags
  default_tags {
    tags = {
      Project   = "webapp"
      ManagedBy = "terraform"
    }
  }
}

# A second configuration of the same provider, for resources that must live in another Region
provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

A resource that needs the second configuration selects it with the provider meta-argument, for example an ACM certificate for CloudFront, which AWS only accepts in us-east-1:

resource "aws_acm_certificate" "cdn" {
  provider          = aws.us_east_1
  domain_name       = "www.example.com"
  validation_method = "DNS"
}

The provider's documentation on the Terraform Registry lists every resource type, its arguments (what you set) and its attributes (what you can read after creation). Keep it open while you write code.

A Web Server on AWS

This configuration builds a network with two public subnets and one web server that installs Nginx at first boot. It uses the provider configuration above and these variables:

variable "region" {
  type    = string
  default = "eu-west-1"
}

variable "instance_type" {
  type    = string
  default = "t3.micro"
}

First the data sources. They read information at plan time and create nothing:

# Availability Zones that are usable in this Region
data "aws_availability_zones" "available" {
  state = "available"
}

# The newest Ubuntu 24.04 LTS image from Canonical
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"] # Canonical's AWS account

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

Then the network. for_each creates one subnet per Availability Zone, and cidrsubnet gives each one its own /24:

locals {
  azs = slice(data.aws_availability_zones.available.names, 0, 2)
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = {
    Name = "webapp-vpc"
  }
}

resource "aws_subnet" "public" {
  for_each = toset(local.azs)

  vpc_id                  = aws_vpc.main.id
  availability_zone       = each.key
  cidr_block              = cidrsubnet(aws_vpc.main.cidr_block, 8, index(local.azs, each.key) + 1)
  map_public_ip_on_launch = true

  tags = {
    Name = "webapp-public-${each.key}"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
}

resource "aws_route_table_association" "public" {
  for_each = aws_subnet.public

  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}

The security group allows HTTP and HTTPS in and everything out. Each rule is its own resource, as the AWS provider recommends:

resource "aws_security_group" "web" {
  name        = "webapp-web"
  description = "Web server"
  vpc_id      = aws_vpc.main.id
}

resource "aws_vpc_security_group_ingress_rule" "web" {
  for_each = toset(["80", "443"])

  security_group_id = aws_security_group.web.id
  description       = "TCP ${each.key} from anywhere"
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "tcp"
  from_port         = tonumber(each.key)
  to_port           = tonumber(each.key)
}

resource "aws_vpc_security_group_egress_rule" "all" {
  security_group_id = aws_security_group.web.id
  description       = "All outbound traffic"
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

Finally the server and two outputs:

resource "aws_instance" "web" {
  ami                    = data.aws_ami.ubuntu.id
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.public[local.azs[0]].id
  vpc_security_group_ids = [aws_security_group.web.id]

  # Require IMDSv2 tokens for the instance metadata service
  metadata_options {
    http_tokens = "required"
  }

  user_data = <<-EOT
    #!/bin/bash
    apt-get update
    apt-get install -y nginx
  EOT

  # user_data runs apt-get at first boot: it needs the internet route and the
  # outbound rule (Terraform removes the security group's default egress rule)
  depends_on = [
    aws_route_table_association.public,
    aws_vpc_security_group_egress_rule.all,
  ]

  tags = {
    Name = "webapp-web"
  }
}

output "web_url" {
  value = "http://${aws_instance.web.public_dns}"
}

output "ami_name" {
  value = data.aws_ami.ubuntu.name
}

Every code block in this part passes terraform validate with AWS provider 6.70. Together, the providers, variables, data sources, network, security group and server files above create 12 objects (the certificate is a separate example), and the server answers on the web_url output a minute or two after terraform apply. Run terraform destroy when you are done.

Dependencies

Terraform reads the references in that configuration and builds this graph. Objects in the same column have no dependency on each other, so Terraform creates them in parallel:

References cover almost every case. Add depends_on only when a dependency is real but invisible in the code. In the example, the instance does not refer to the route table or to the outbound rule, but its boot script needs both to download Nginx: the internet route, and outbound traffic, which a security group managed by Terraform only allows once the egress rule exists. depends_on makes Terraform create those first. depends_on takes whole resources or modules, never single attributes, and it makes plans more conservative, so use it sparingly.

You can draw the graph of any configuration with terraform graph. Since Terraform 1.16 it also writes Mermaid, which GitHub and many wikis render. Here is the graph of a small local project used in the next sections. Create a directory called resources with this main.tf, in which a configuration file depends on a random name and a deployment step depends on the file:

terraform {
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.9"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
  }
}

variable "release" {
  type    = string
  default = "v1"
}

resource "random_pet" "app" {
  length = 2
}

resource "local_file" "config" {
  filename = "${path.module}/app.conf"
  content  = "name=${random_pet.app.id}\nrelease=${var.release}\n"
}

# Stands in for a deployment step: runs a command when it is created
resource "terraform_data" "deploy" {
  input = local_file.config.content_sha256

  provisioner "local-exec" {
    command = "echo deploying ${random_pet.app.id}"
  }

  lifecycle {
    replace_triggered_by = [local_file.config]
  }
}

After terraform init, draw the graph:

Each arrow points from a resource to something it depends on: the file needs the name, and the deployment step needs the file. Without -format, terraform graph writes the Graphviz DOT format; pipe it to dot -Tsvg > graph.svg if you have Graphviz installed.

Meta-Arguments on a Local Project

The rest of this part runs on your own machine. local_file, random_pet and Terraform's built-in terraform_data resource behave like cloud resources in every way that matters here: they are created, updated, replaced and destroyed, and they live in the state.

Provisioners and replace_triggered_by

terraform_data.deploy above has a provisioner: a command Terraform runs on the machine where it runs (local-exec) or on a new server over SSH (remote-exec) after creating the resource. Apply the project:

Now change the release. local_file.config must be replaced because its content changes, and replace_triggered_by replaces terraform_data.deploy with it, which runs the provisioner again:

The grep keeps only the summary lines of each change. Provisioners are a last resort: Terraform cannot plan what a script does, a failed script taints the resource (it is replaced on the next apply), and the script does not run again unless the resource is replaced. To configure servers, prefer a machine image or cloud-init user_data; to run a command when something changes, use an action or your CI pipeline.

count and for_each

count creates a number of copies and gives each an index; for_each takes a map or a set of strings and gives each copy a key. In a new directory, copies, this main.tf creates the same three files both ways:

variable "sites" {
  type    = list(string)
  default = ["blog", "shop", "docs"]
}

resource "local_file" "by_count" {
  count    = length(var.sites)
  filename = "${path.module}/count/${var.sites[count.index]}.conf"
  content  = "server_name ${var.sites[count.index]}.example.com;\n"
}

resource "local_file" "by_key" {
  for_each = toset(var.sites)
  filename = "${path.module}/for_each/${each.key}.conf"
  content  = "server_name ${each.key}.example.com;\n"
}

output "count_files" {
  value = local_file.by_count[*].filename
}

output "for_each_files" {
  value = { for site, f in local_file.by_key : site => f.filename }
}

The addresses tell the story: by_count[0] to by_count[2] are numbered, by_key["blog"] and friends are named. Now remove blog, the first site, and plan:

With count, every element after the removed one shifts down by one index: by_count[0] now has to become shop, by_count[1] becomes docs, and by_count[2] is deleted. Three changes for one removal, and for real servers or databases "must be replaced" means downtime or data loss. With for_each, only by_key["blog"] goes away.

The rules that follow:

  • Use for_each whenever the copies have an identity (a name, a region, a user). Keys must be known at plan time, so build them from variables and literals, not from attributes of resources that do not exist yet.
  • Use count for "N identical things" and for the on/off switch count = var.create_dns ? 1 : 0. Refer to the optional resource with one(aws_route53_record.www[*].fqdn) or aws_route53_record.www[0].
  • To switch an existing resource from count to for_each without recreating anything, use moved blocks (Part 5).
  • More patterns are in How to Reference Resources Created With for_each and How to Get List Index When Using for_each.

lifecycle

The lifecycle block changes how Terraform handles one resource. The next examples use another new directory, lifecycle, with this main.tf:

variable "image" {
  type    = string
  default = "ubuntu-24.04"
}

# Stands in for a server: a new name whenever the image changes
resource "random_pet" "server" {
  keepers = {
    image = var.image
  }

  lifecycle {
    create_before_destroy = true

    precondition {
      condition     = startswith(var.image, "ubuntu-")
      error_message = "Only Ubuntu images are supported, got \"${var.image}\"."
    }
  }
}

# Stands in for a database you must never delete by accident
resource "local_file" "database" {
  filename = "${path.module}/database.txt"
  content  = "important data\n"

  lifecycle {
    prevent_destroy = true
  }
}

create_before_destroy. By default, Terraform deletes the old object before it creates the replacement. For a server behind a load balancer, that is downtime. With create_before_destroy, the plan shows +/- and the old object is kept (as a "deposed" object) until the new one exists:

Two warnings: the new object must be able to exist next to the old one (a fixed name that must be unique, such as an S3 bucket name or a security group name, breaks this, so use name_prefix where the provider offers it), and the setting spreads to the resources it depends on.

precondition and postcondition. A precondition is checked before Terraform plans the resource and stops the run with your message. A postcondition checks the result after a resource or data source is read or created, for example that an AMI lookup returned an image for the right CPU architecture:

Use variable validation (Part 6) for rules about one input, and preconditions for rules that combine inputs with data sources or other resources.

prevent_destroy. Any plan that would delete the object fails, including terraform destroy:

It protects against mistakes in code and in commands. It does not protect against someone deleting the block together with its lifecycle setting, and it does not stop deletion in the cloud console, so also turn on the provider's own protection where it exists (deletion_protection on RDS and load balancers, disable_api_termination on EC2).

ignore_changes. Tells Terraform to stop comparing some arguments, for values that something else manages on purpose:

resource "aws_autoscaling_group" "web" {
  name_prefix         = "webapp-"
  min_size            = 2
  max_size            = 10
  vpc_zone_identifier = [for s in aws_subnet.public : s.id]

  launch_template {
    id      = aws_launch_template.web.id
    version = "$Latest"
  }

  lifecycle {
    # Scaling policies change the desired capacity at runtime
    ignore_changes = [desired_capacity]
  }
}

resource "aws_launch_template" "web" {
  name_prefix   = "webapp-"
  image_id      = data.aws_ami.ubuntu.id
  instance_type = var.instance_type

  metadata_options {
    http_tokens = "required"
  }
}

ignore_changes = all turns Terraform into a "create once" tool for that resource. Use it rarely, because drift in ignored arguments becomes invisible.

replace_triggered_by replaces the resource when another resource or attribute changes, as terraform_data.deploy showed above.

destroy = false (Terraform 1.16 and later) makes Terraform forget the object instead of deleting it when the resource is replaced, when you run terraform destroy, or when an instance drops out of count or for_each. The setting lives in the resource block, so deleting the whole block deletes the protection too; to stop managing a resource you remove from the code, use a removed block, shown in Part 5.

Actions

Terraform 1.14 added actions: operations a provider defines that are not create, read, update or delete. Examples in the big providers are invalidating a CloudFront cache, invoking a Lambda function or stopping an EC2 instance. You declare an action block and trigger it from a resource's lifecycle or run it on demand with terraform apply -invoke=action.<type>.<name>. The local provider ships local_command. In a new directory, actions:

resource "local_file" "page" {
  filename = "${path.module}/index.html"
  content  = "<h1>Hello</h1>\n"

  lifecycle {
    action_trigger {
      events  = [after_create, after_update]
      actions = [action.local_command.purge_cache]
    }
  }
}

action "local_command" "purge_cache" {
  config {
    command   = "echo"
    arguments = ["cache purged for index.html"]
  }
}

The plan counts actions separately ("Actions: 1 to invoke"). Actions replace many uses of provisioners and terraform_data tricks, but only where a provider offers the action you need.

Data Sources

A data source reads information during the plan. You have seen aws_ami and aws_availability_zones; these are also common:

# Who and where am I?
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

# A VPC created by another team or another Terraform configuration, found by tag
data "aws_vpc" "shared" {
  tags = {
    Name = "shared-services"
  }
}

data "aws_subnets" "shared_private" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.shared.id]
  }

  tags = {
    Tier = "private"
  }

  lifecycle {
    postcondition {
      condition     = length(self.ids) > 0
      error_message = "No private subnets found in the shared VPC."
    }
  }
}

output "deploy_target" {
  value = "${data.aws_caller_identity.current.account_id} in ${data.aws_region.current.region}"
}

A data source that looks up one object, such as aws_vpc or aws_ami, fails the plan when it finds nothing, and also when it finds more than one match unless it has a way to pick one (most_recent = true on aws_ami picks the newest image). That is usually what you want: the code depends on that object existing. Lookups that return a list, such as aws_subnets, succeed with an empty list instead, so add a postcondition when you need at least one result, as aws_subnets.shared_private does above. Two habits help:

  • Filter precisely. most_recent = true plus a name pattern and an owners list for images; tags or names for networks.
  • Avoid data sources for things in the same configuration. If Terraform creates the VPC, refer to aws_vpc.main directly. A data source that reads it back depends on timing and can fail on the first apply.

When a data source depends on a resource that changes in the same plan, Terraform reads it during apply instead and shows it with <= in the plan. The post How are data sources used in Terraform? has more examples.

Targeting and Forcing Replacement

Two flags change what one run does without changing the code:

  • terraform apply -replace=aws_instance.web replaces one object even though nothing changed, for example a server in a bad state. It replaces the old terraform taint command.
  • terraform apply -target=aws_instance.web limits the run to one resource and its dependencies. Terraform prints a warning every time, because the rest of the configuration is not checked. Use it to recover from errors, not as a habit. The post I would like to run Terraform only for a specific resource covers the details.

Common Mistakes

  • Using count for things with an identity. Removing one element shifts every index after it. Use for_each.
  • for_each over values unknown at plan time. for_each = toset(aws_instance.web[*].id) fails on the first plan. Use keys you write yourself.
  • Hard-coded image IDs. AMI IDs differ per Region and go stale. Look them up with a data source or pass them in from your image pipeline.
  • Unique names plus create_before_destroy. The new object cannot be created while the old one holds the name.
  • Provisioners for configuration management. They run once, are not planned, and taint resources when they fail.

Summary

  • Resources create and manage objects; data sources read existing ones.
  • References define the order; depends_on covers hidden dependencies; terraform graph -format=mermaid draws it.
  • Prefer for_each over count for anything with an identity.
  • lifecycle controls replacement order (create_before_destroy), safety (prevent_destroy, preconditions), drift you accept (ignore_changes) and linked replacement (replace_triggered_by).
  • Actions run provider-defined operations from the lifecycle of a resource or on demand.

In Part 5 you look inside the state that records all of these objects.

Keep Learning

Updated:

Tags

Found an issue?