Initializing Enclave...

How to Fix 'Provider null Produced an Invalid Plan for null_resource' in Terraform

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins


TL;DR

  • What broke: Terraform's null provider received a plan for a null_resource where one or more triggers values were unknown/computed at plan time, or a lifecycle block contained an expression the provider cannot evaluate before apply — violating the provider contract.
  • How to fix it: Replace unknown computed references in triggers with static values, use depends_on to sequence the dependency, or migrate to the native terraform_data resource (Terraform ≥ 1.4) which handles this correctly.
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your null_resource block and get a corrected diff without sending your config to any external server.

The Incident (What Does the Error Mean?)

Raw error output from terraform plan or terraform apply:

Error: Provider "registry.terraform.io/hashicorp/null" produced an invalid plan
  for null_resource.example,
  provider "registry.terraform.io/hashicorp/null" planned an invalid value
  for null_resource.example.triggers["instance_id"]: planned value
  cty.StringVal("") does not match config value
  cty.UnknownVal(cty.String)

Immediate consequence: The entire Terraform plan is aborted. No resources in the current run are created, updated, or destroyed. If this null_resource is gating a provisioner (e.g., a remote-exec or local-exec running a migration script), that downstream automation is completely blocked. In a pipeline, this surfaces as a hard exit 1, failing the stage and potentially leaving infrastructure in a partially-converged state if this was a terraform apply -auto-approve mid-run.

The null provider is strict: it requires that every key in the triggers map resolves to a known string value at plan time. If any trigger value is (known after apply) — meaning it references an attribute of a resource not yet created — the provider cannot produce a valid plan and throws this error.


The Attack Vector / Blast Radius

This is not a security vulnerability, but the blast radius in a production pipeline is severe:

  1. Cascading pipeline failure. null_resource blocks are almost always used as orchestration glue — running DB migrations, invoking Ansible, or signaling external systems. A broken plan here means none of that runs. A zero-downtime deployment that depends on a migration step will hang or deploy broken application code against an un-migrated schema.

  2. State corruption risk on partial applies. If this error surfaces mid-apply (not mid-plan), resources created before the null_resource in the dependency graph are now live in your infrastructure but not consistently reflected in what downstream modules expect. Rolling back requires manual terraform state rm surgery.

  3. The silent trigger trap. The most dangerous variant: a developer uses triggers = { always_run = timestamp() } to force re-execution, but then adds a second trigger referencing a computed value. The timestamp() trigger works fine in isolation, masking the issue until the computed resource is introduced — often in a PR that looks unrelated.

  4. Terraform Cloud / Enterprise run failures. In TFC, a failed plan locks the workspace run queue. Depending on your workspace settings, this can block ALL other runs against that workspace, causing a full infrastructure freeze for the team.

Root cause taxonomy:

Cause Frequency Severity
Trigger references (known after apply) attribute Very High Blocks plan
Trigger value type is not string (e.g., list, map) High Blocks plan
lifecycle.replace_triggered_by referencing unknown resource Medium Blocks plan
Using deprecated null_resource where terraform_data is required Low (TF 1.4+) Blocks plan

How to Fix It (The Solution)

Basic Fix — Defer the Unknown Value with depends_on

The broken pattern: a trigger directly references an attribute that won't exist until apply.

 resource "aws_instance" "app" {
   ami           = "ami-0abcdef1234567890"
   instance_type = "t3.micro"
 }
 
 resource "null_resource" "run_migration" {
   triggers = {
-    instance_id = aws_instance.app.id
+    # aws_instance.app.id is (known after apply) — cannot be used as a trigger directly
+    # Use a static sentinel or the instance's stable config attribute instead
+    instance_ami = aws_instance.app.ami
   }
 
+  depends_on = [aws_instance.app]
+
   provisioner "local-exec" {
     command = "./run_migration.sh"
   }
 }

Why this works: aws_instance.app.ami is a known value at plan time (you provided it in config). aws_instance.app.id is assigned by AWS and is unknown until the resource is created. The depends_on ensures ordering without requiring the unknown value in the trigger map.


Enterprise Best Practice — Migrate to terraform_data (Terraform ≥ 1.4)

terraform_data is the official successor to null_resource. It is a built-in resource (no provider required) and handles unknown values in triggers_replace gracefully by deferring evaluation to apply time.

-resource "null_resource" "run_migration" {
-  triggers = {
-    instance_id = aws_instance.app.id
-  }
-
-  provisioner "local-exec" {
-    command = "./run_migration.sh ${aws_instance.app.private_ip}"
-  }
-}
+resource "terraform_data" "run_migration" {
+  # triggers_replace accepts any type and handles (known after apply) values correctly
+  triggers_replace = [
+    aws_instance.app.id,
+    aws_instance.app.private_ip
+  ]
+
+  provisioner "local-exec" {
+    command = "./run_migration.sh ${aws_instance.app.private_ip}"
+  }
+}

Remove the null provider block if null_resource was your only usage:

 terraform {
   required_providers {
-    null = {
-      source  = "hashicorp/null"
-      version = "~> 3.0"
-    }
   }
 }

This reduces your provider lock surface and eliminates the entire class of "invalid plan" errors from the null provider.


Fix for the always_run Anti-Pattern

If you're using timestamp() to force re-runs, this is a footgun:

 resource "null_resource" "always_run" {
   triggers = {
-    always_run  = timestamp()
-    instance_id = aws_instance.app.id   # This kills the plan
+    always_run = timestamp()
+    # Do NOT mix timestamp() with (known after apply) values in the same triggers map
+    # Use depends_on for ordering instead
   }
+
+  depends_on = [aws_instance.app]
 }

💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. terraform validate as a Pre-Commit Hook

# .git/hooks/pre-commit
terraform validate -no-color
if [ $? -ne 0 ]; then
  echo "[BLOCKED] terraform validate failed. Fix HCL before committing."
  exit 1
fi

terraform validate catches type mismatches in trigger values before a plan is ever attempted.

2. Checkov Policy — Flag null_resource Trigger Anti-Patterns

Add a custom Checkov check or use tflint with the hashicorp/null ruleset:

# In your CI pipeline (GitHub Actions, GitLab CI, etc.)
tflint --enable-rule=terraform_required_version
checkov -d . --framework terraform --check CKV_TF_1

For teams on Terraform 1.4+, enforce terraform_data over null_resource via an OPA policy:

# policy/deny_null_resource.rego
package terraform.deny_null_resource

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "null_resource"
  msg := sprintf(
    "[POLICY VIOLATION] null_resource '%s' is deprecated. Migrate to terraform_data (TF >= 1.4).",
    [resource.address]
  )
}

Integrate with Terraform Cloud via Sentinel or OPA in your Atlantis atlantis.yaml:

# atlantis.yaml
projects:
  - name: production
    dir: ./infra
    workflow: default
    apply_requirements:
      - approved
      - mergeable
    pre_workflow_hooks:
      - run: checkov -d . --framework terraform --soft-fail-on LOW

3. Renovate / Dependabot for Provider Pinning

Keep hashicorp/null pinned. Breaking behavior between null provider minor versions has historically caused plan failures:

# versions.tf
terraform {
  required_providers {
    null = {
      source  = "hashicorp/null"
      version = "= 3.2.2"  # Pin exact, not ~>
    }
  }
}

Upgrade path: Audit all null_resource usages with:

grep -r 'null_resource' . --include='*.tf' -l

For each file found, evaluate whether terraform_data is a drop-in replacement. In 95% of cases, it is.

Related Diagnostics

"Part of the Syntax Utility Matrix."

View all 153 Syntax Tools →