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
nullprovider received a plan for anull_resourcewhere one or moretriggersvalues were unknown/computed at plan time, or alifecycleblock contained an expression the provider cannot evaluate before apply — violating the provider contract. - How to fix it: Replace unknown computed references in
triggerswith static values, usedepends_onto sequence the dependency, or migrate to the nativeterraform_dataresource (Terraform ≥ 1.4) which handles this correctly. - Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your
null_resourceblock 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:
Cascading pipeline failure.
null_resourceblocks 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.State corruption risk on partial applies. If this error surfaces mid-apply (not mid-plan), resources created before the
null_resourcein the dependency graph are now live in your infrastructure but not consistently reflected in what downstream modules expect. Rolling back requires manualterraform state rmsurgery.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. Thetimestamp()trigger works fine in isolation, masking the issue until the computed resource is introduced — often in a PR that looks unrelated.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.