Terraform Plan JSON in CI: Versioning, Unknown Values, and Policy Boundaries
Parse Terraform plan JSON with version-aware gates for actions, unknown values, and secrets, without treating a green check as infrastructure approval.
Terraform’s human-readable plan is designed for review. A CI job often needs machine-readable output too: block a class of actions, identify a resource that needs extra approval, or attach a structured summary to a deployment record. terraform show -json provides that representation, but the JSON is not a simplified, permanently fixed copy of terminal output. It is a versioned interface containing values, partial values, action details, configuration, state, and potentially sensitive data.
The safe design is to use JSON as evidence for a narrow, explicit policy, while keeping the saved plan and human approval as separate controls. A parser that accepts any document and prints “safe” is not a security boundary. It must reject formats it cannot understand, treat unknown values as unknown, protect the artifacts it reads, and leave broader infrastructure intent to review.
Generate JSON from the exact saved plan
terraform plan -out=review.tfplan writes an opaque saved plan. Passing that file to terraform show -json renders the plan, configuration, and current state as JSON. The JSON is for analysis; Terraform’s saved plan, not a regenerated command line or a hand-edited JSON document, is what an approved apply should consume.
set -eu
umask 077
run_dir=$(mktemp -d "${TMPDIR:-/tmp}/terraform-plan.XXXXXX")
plan_file="$run_dir/review.tfplan"
json_file="$run_dir/review.tfplan.json"
terraform plan -out="$plan_file"
terraform show -json "$plan_file" > "$json_file"
The fresh, per-run directory prevents an earlier plan from being mistaken for this run’s output; set -e stops before show if planning fails. In CI, carry the selected plan path with the protected plan artifact through review and apply; do not regenerate the plan after approval. Keep the directory private and retain its saved plan only as long as needed for review and the matching apply. The plan file contains the configuration, planned values, and input variables, including sensitive values in cleartext. The JSON rendering can expose sensitive state values in plain text as well. Restrict access to both files, do not publish them as ordinary CI logs or broadly readable artifacts, and remove them under the project’s retention policy. A sensitive marker is metadata for display and policy; it does not encrypt the corresponding value.
The show command’s JSON mode works with Terraform 0.12 and later, but the format is versioned independently. The current format uses a format_version such as 1.0. HashiCorp’s compatibility guidance says minor versions add backward-compatible fields, which consumers should tolerate by ignoring unknown properties; an unsupported major version represents incompatible changes and should be rejected. Avoid pinning a policy to every optional key appearing in one sample document.
There are also plan preconditions. HashiCorp documents that the plan supplied to show -json must not have been created with -refresh=false; provider schema upgrades may also require state upgrade before a plan can be displayed. A parser should fail when terraform show fails rather than evaluating an empty or stale JSON file left by an earlier run.
Read actions as arrays, not as labels
The top-level resource_changes array describes per-instance planned actions, including no-op entries. Its change.actions member is an array. Ordinary actions include no-op, create, read, update, and delete. Replacement is represented as two actions, either delete then create or create then delete. A check for the word replace will therefore miss real replacements. If a policy forbids any destroy, checking whether the action array contains delete covers both replacement orders and direct deletion.
That simple rule is still only a resource-action gate. It does not decide whether a create is affordable, whether an update changes a public endpoint, whether an output removal breaks a consumer, or whether a deletion is approved for a specific maintenance window. A full policy needs resource-specific context, expected addresses and attributes, workspace and environment identity, and a human-readable change review. Preserve the full Terraform address in decision records; do not reduce a resource to its short type/name if modules or repeated instances can distinguish it.
Here is a deliberately narrow jq gate for current format version 1.x. It accepts exactly one JSON document only when Terraform marks it applyable, complete, and not errored, the resource list and each action array match the action forms this policy understands, and no resource action includes deletion. An unfamiliar action form fails closed and needs a reviewed policy update.
policy='
def valid_actions:
((.change.actions | type) == "array")
and (
.change.actions == ["no-op"]
or .change.actions == ["create"]
or .change.actions == ["read"]
or .change.actions == ["update"]
or .change.actions == ["delete"]
or .change.actions == ["delete", "create"]
or .change.actions == ["create", "delete"]
);
def no_delete:
valid_actions
and ((.change.actions | index("delete")) == null);
def valid_plan:
((.format_version | type) == "string")
and (.format_version | test("^1\\.[0-9]+$"))
and (.applyable == true)
and (.complete == true)
and (.errored == false)
and ((.resource_changes | type) == "array")
and all(.resource_changes[]; no_delete);
length == 1 and (.[0] | valid_plan)
'
jq -e -s "$policy" "$json_file" > /dev/null
Exercise the same filter with a few representative fixtures before relying on it. These examples check both intended decisions and malformed or unsupported shapes:
set -eu
check() {
name=$1
expected=$2
input=$3
if printf '%s\n' "$input" | jq -s -e "$policy" > /dev/null 2>&1; then
actual=pass
else
actual=reject
fi
if [ "$actual" != "$expected" ]; then
printf 'fixture failed: %s (expected %s, got %s)\n' "$name" "$expected" "$actual" >&2
return 1
fi
}
check update pass '{"format_version":"1.0","applyable":true,"complete":true,"errored":false,"resource_changes":[{"change":{"actions":["update"]}}]}'
check minor-version pass '{"format_version":"1.12","applyable":true,"complete":true,"errored":false,"resource_changes":[]}'
check empty-resource-list pass '{"format_version":"1.0","applyable":true,"complete":true,"errored":false,"resource_changes":[]}'
check delete reject '{"format_version":"1.0","applyable":true,"complete":true,"errored":false,"resource_changes":[{"change":{"actions":["delete"]}}]}'
check replacement-delete-create reject '{"format_version":"1.0","applyable":true,"complete":true,"errored":false,"resource_changes":[{"change":{"actions":["delete","create"]}}]}'
check replacement-create-delete reject '{"format_version":"1.0","applyable":true,"complete":true,"errored":false,"resource_changes":[{"change":{"actions":["create","delete"]}}]}'
check unsupported-major reject '{"format_version":"2.0","applyable":true,"complete":true,"errored":false,"resource_changes":[]}'
check malformed-version reject '{"format_version":"1.","applyable":true,"complete":true,"errored":false,"resource_changes":[]}'
check missing-actions reject '{"format_version":"1.0","applyable":true,"complete":true,"errored":false,"resource_changes":[{"change":{}}]}'
check empty-actions reject '{"format_version":"1.0","applyable":true,"complete":true,"errored":false,"resource_changes":[{"change":{"actions":[]}}]}'
check missing-complete reject '{"format_version":"1.0","applyable":true,"errored":false,"resource_changes":[]}'
check missing-resource-list reject '{"format_version":"1.0","applyable":true,"complete":true,"errored":false}'
check unknown-action reject '{"format_version":"1.0","applyable":true,"complete":true,"errored":false,"resource_changes":[{"change":{"actions":["unrecognized"]}}]}'
check multiple-documents reject '{"format_version":"1.0","applyable":true,"complete":true,"errored":false,"resource_changes":[]}
{"format_version":"1.0","applyable":true,"complete":true,"errored":false,"resource_changes":[]}'
printf 'all jq policy fixtures passed\n'
An empty resource_changes array passes this specific no-resource-destroy rule: with no resource-action entries, there is no resource deletion for this rule to flag. It does not imply that output changes, drift, or other policy inputs are safe.
Use the command’s exit status as the gate: jq -e exits unsuccessfully for false, and parse errors also fail. Slurping input and requiring one document makes empty or concatenated JSON fail rather than letting the last result decide the exit status. The filter intentionally rejects missing fields, so older output that lacks complete will need an explicitly versioned policy rather than an implicit pass. It does not inspect output changes, action reasons, resource_drift, or value-level rules. Add those only as separate reviewed controls with fixtures for the exact Terraform versions and resource cases in use. Never take this example as approval to auto-apply every plan that happens to pass it.
Unknown values are not empty values
The planned_values object is partial. Terraform omits values that will not be known until apply. In ordinary value representations, unknown values can be omitted or represented as null, so they may be indistinguishable from absent values. Plan-specific structures such as proposed_unknown and each resource change’s after_unknown identify values that are not yet known.
That distinction is operationally important. If a policy requires an exact network range, image identifier, or account boundary, a value that will only be known during apply cannot satisfy the check simply because the JSON has no visible string to reject. Decide which unknown paths are acceptable for each resource type. If an unknown field is essential to the decision, stop for a new plan after the dependency becomes known, or require a human to approve the unresolved risk. A policy that recursively rejects every unknown may be too strict for computed identifiers; a policy that ignores all unknowns can make its apparent precision meaningless.
Terraform also serializes complex HCL values to JSON in a way that can lose type detail: lists, sets, and tuples become arrays, while maps and objects become objects. If the rule depends on Terraform’s original type or on how an expression was formed, JSON values alone may not carry enough information. Use the typed type representation where provided and examine configuration data deliberately instead of inferring types from a JSON shape.
Redaction and sensitive paths
The before_sensitive and after_sensitive structures identify sensitive paths by marking sensitive leaves, and sensitive_values serves a similar purpose for resource values. A review interface should combine those markers with the actual before/after trees and redact before rendering. Do not assume that the values are already masked, or that a log scrubber knows Terraform’s nested schema. The safest policy output normally records the resource address, action, decision, and a reason code, not the entire value object.
The markers are useful for presentation but are not a secret-management system. Files generated from plans should be treated as confidential even if terminal output masked them. Limit CI job permissions, artifact downloads, retention, and debug traces. Do not run jq . to dump the full object into a log, and do not include the JSON in pull-request comments unless a separately reviewed redaction layer has removed sensitive leaves.
Keep checks and policy separate
Plan JSON includes checks data for preconditions and postconditions. HashiCorp marks the JSON representation of checks as experimental and warns that details can change even in minor CLI versions. It may be useful for an informative summary, but a long-lived enforcement rule should not silently assume every check status or address field is a stable protocol. Pin supported Terraform releases, test the exact structures, reject unfamiliar shapes, and revisit the integration when upgrading Terraform.
A robust review pipeline therefore has several distinct decisions: Terraform successfully generated the plan; the JSON format is supported; an organization-specific policy found no disallowed actions or values; a reviewer approved the exact plan; and the apply command uses that same saved plan while the state assumptions remain valid. Machine-readable output makes those controls automatable, but does not collapse them into one trustworthy boolean.
Related:
- Terraform Targeted Plans and Resource Replacement: Recovery Without Hiding Drift
- Terraform Tests and Mock Providers: Verify Module Contracts Without Cloud Applies
Sources: