TFLAB20-null_resource and terraform_data: Actions Without Cloud Resources

null_resource and terraform_data: Actions Without Cloud Resources

🔧 Terraform Core ⭐ Intermediate null_resource terraform_data triggers triggers_replace

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.

Your Objectives
  • 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
✓ /tmp/tf-triggers.log After Two Applies
[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