null_resource and terraform_data: Actions Without Cloud Resources
Scenario
After Terraform creates an S3 bucket, you need to run a registration script. But the script isn't tied to any specific cloud resource — it's a standalone action. You need a way to run provisioners without a real cloud resource to attach them to.
null_resource (legacy) and terraform_data (modern, Terraform 1.4+) both solve this. They are "empty" resources that exist only to hold provisioners and triggers.
- Create a null_resource with a triggers map and a local-exec provisioner.
- Create a terraform_data resource with triggers_replace and a provisioner.
- Observe that changing a trigger value causes the resource to be replaced, re-running the provisioner.
- Understand when to use each — prefer terraform_data for new code.
Additional Context
null_resource requires adding the hashicorp/null provider. terraform_data is built into Terraform 1.4+ — no extra provider needed. They behave identically: a resource that does nothing except hold triggers and provisioners.
Triggers are the key mechanism: when a trigger value changes, the resource is destroyed and recreated, which re-runs all attached provisioners. If triggers don't change, nothing happens.
main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
null = { source = "hashicorp/null", version = "~> 3.0" }
}
}
provider "aws" { region = "us-east-1" }
data "aws_caller_identity" "current" {}
variable "app_version" { default = "1.0.0" }
resource "aws_s3_bucket" "app" {
bucket = "trigger-demo-${data.aws_caller_identity.current.account_id}"
tags = { ManagedBy = "terraform" }
}
# ─── null_resource (LEGACY approach) ───
# Requires the hashicorp/null provider.
# When bucket ID or app_version changes → resource is replaced → provisioner re-runs.
resource "null_resource" "register_legacy" {
triggers = {
bucket_id = aws_s3_bucket.app.id
app_version = var.app_version
}
provisioner "local-exec" {
command = "echo '[null_resource] Registering bucket ${aws_s3_bucket.app.id} app_version=${var.app_version}' >> /tmp/tf-triggers.log"
}
}
# ─── terraform_data (MODERN approach — Terraform 1.4+) ───
# No extra provider needed. Cleaner API.
# triggers_replace accepts a list of values; any change triggers replacement.
resource "terraform_data" "register_modern" {
triggers_replace = [aws_s3_bucket.app.id, var.app_version]
provisioner "local-exec" {
command = "echo '[terraform_data] Registering bucket ${aws_s3_bucket.app.id} app_version=${var.app_version}' >> /tmp/tf-triggers.log"
}
}
# ─── terraform_data with input/output ───
# terraform_data can also store arbitrary data
resource "terraform_data" "version_store" {
input = var.app_version
}
outputs.tf
output "bucket_name" { value = aws_s3_bucket.app.id }
output "stored_version" { value = terraform_data.version_store.output }Workflow Commands
terraform init && terraform apply
# Check the log
cat /tmp/tf-triggers.log
# Change app_version → both null_resource and terraform_data are replaced
terraform apply -var="app_version=2.0.0"
cat /tmp/tf-triggers.log # two new entries
terraform destroy[null_resource] Registering bucket trigger-demo-123456789012 app_version=1.0.0 [terraform_data] Registering bucket trigger-demo-123456789012 app_version=1.0.0 [null_resource] Registering bucket trigger-demo-123456789012 app_version=2.0.0 [terraform_data] Registering bucket trigger-demo-123456789012 app_version=2.0.0

