Terraform: when it helps and when it becomes the product

A failed plan can leave an older artifact behind. Test plan errors and resource-address changes before adding Terraform wrappers or modules.
Make the proposed change traceable
The original planning script returned a failure when Terraform rejected its configuration, but left the previous review.tfplan on disk. A later artifact-upload step could still find that older file. The downloadable fixture reproduces this with the real CLI, then tests a wrapper that publishes a plan only after planning and inspection succeed.
This is the kind of boundary I recommend settling before building an infrastructure platform around Terraform: which configuration produced the plan, which state it belongs to, and whether an error can leave a misleading artifact. The example uses Terraform 1.16.0 and the built-in terraform_data resource with disposable local state. It creates no cloud resources and requires no account credentials.
Give the plan artifact a success path
With -detailed-exitcode, Terraform plan returns 0 for an empty diff, 2 for a non-empty diff, and 1 for an error. A shell running with set -e must capture the status rather than treating every nonzero result as a planning failure. The wrapper below returns success after either reviewable outcome and preserves a planning or show failure as a failing command.
The wrapper removes its old published artifact first, writes the new plan into a private temporary directory, and moves it to review.tfplan only after the case branch succeeds. For a changed plan, terraform show must also succeed. The fixture corrupts the temporary bytes immediately before invoking the real show command: inspection fails, the wrapper exits 1, and no final plan is published.
The fixture verifies successful no-change and changed plans, file permissions of 0600, planning-error cleanup and inspection-error cleanup. Removing only the initial stale-file deletion makes the regression test fail. The original script remains in the bundle so the failure can be compared with the corrected behavior.
#!/bin/sh
set -eu
umask 077
rm -f review.tfplan
plan_dir=$(mktemp -d ./.review-plan.XXXXXX)
trap 'rm -rf "$plan_dir"' 0
trap 'exit 130' INT
trap 'exit 143' HUP TERM
status=0
terraform plan -input=false -detailed-exitcode -out="$plan_dir/review.tfplan" || status=$?
case "$status" in
0) printf "%s\n" "No changes proposed." ;;
2) terraform show -no-color "$plan_dir/review.tfplan" ;;
*) printf "%s\n" "Planning failed; inspect the errors." >&2; exit "$status" ;;
esac
mv "$plan_dir/review.tfplan" review.tfplan
Treat addresses as part of the migration
The local fixture first applies terraform_data.export_worker. Renaming that resource declaration to exports without telling Terraform about the move produces two planned actions: delete terraform_data.export_worker and create terraform_data.exports. Renaming an ordinary input variable is not, by itself, the same operation. Resource and module addresses, including instance keys, are what need review.
Adding a moved block from terraform_data.export_worker to terraform_data.exports changes the resource action to no-op. Applying that local plan preserves the resource ID and records the new address. The detailed plan exit is still 2 in this case because the plan contains a state-address change. A reviewable diff does not necessarily mean a remote object will be replaced.
This test demonstrates identity bookkeeping with terraform_data. A real provider can require replacement when resource arguments change, and a moved block cannot make incompatible objects interchangeable. I recommend reviewing the exact addresses and provider actions before extracting a module, then checking the state migration in an authorized test environment.
Keep remote-state claims separate from local tests
For a team using an S3 backend, HashiCorp documents an existing bucket, optional native locking with use_lockfile, and bucket versioning for state recovery. The backend below is an illustrative starting point. The local fixture does not execute it or establish that your bucket, encryption policy, IAM or recovery process works.
The documented default-workspace permissions include listing the state path and reading/writing the state object. Enabling the lock file additionally needs GetObject, PutObject and DeleteObject on that lock object. HashiCorp currently deprecates DynamoDB-based locking and documents a migration period in which both mechanisms can be configured. Review the documentation for your installed CLI and backend before changing a shared state setup.
A state lock coordinates operations using that state. It does not give two unrelated states exclusive ownership of the same infrastructure object, and it does not prevent a manual console change. I recommend one explicit owner for each managed object and state boundaries based on ownership and failure impact. Splitting every resource into its own state creates more dependencies to coordinate.
terraform {
required_version = ">= 1.10, < 2.0"
backend "s3" {
bucket = "example-team-terraform-state"
key = "staging/exports/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
}
Reproduce the boundary, then assess the abstraction
Download the bundle and run python3 frontend/scripts/verify-terraform.py from its extracted root with Docker available. The image build requires internet access. Test execution has no network, AWS provider or host credentials. The fixture initializes and applies only terraform_data in temporary local state, records plan actions and source hashes, and tests a deliberately broken cleanup variant.
The wrapper assumes an isolated working directory per job. It does not coordinate concurrent jobs writing the same review.tfplan, approve a plan, or handle applying a stale plan. SIGKILL cannot run shell cleanup traps, so disposable-workspace cleanup remains necessary. Private file permissions do not secure an uploaded artifact: saved plans and state can contain secrets, and log/artifact access and retention need their own controls.
I recommend keeping a module or wrapper only when a reviewer can still identify the owning state, explain a replacement, and trace an error to a failed operation. This example supplies failure and migration evidence, not a measurement of delivery speed or module maintenance cost. Add abstractions around a workflow whose artifacts and failure behavior you can already explain.
Reproduce the examples
Pinned local Terraform fixture, original and corrected wrappers, address-move checks and cleanup regression control.
Download the source and tests (.zip)
