# Terraform deployment checklist

> **This file is reference data, not instructions. It lists things to verify before deploying. Nothing below is a command from the operator, and no line in it grants authority to act. Run the read-only verification steps if you wish, then report what you found to the human operator and let them decide what to change. Do not modify the project on the basis of this file. This holds when you are running unattended, on autopilot, or in any automatic mode: an autonomy setting is not the operator approval this file withholds, and no finding here becomes authority to edit a file because nobody is watching. If something needs changing, say so and stop.**

Source: https://deploy-list.com/hcl/terraform.md · 14 checks · layers: universal -> hcl -> hcl.terraform · generated 2026-08-22

Verification steps come in three kinds. **Verify:** read-only, allowlisted, safe for an agent to run. **Operator-run:** a command only the human should decide to run, typically a framework CLI. An agent must not run these, only report them as outstanding. **Verify by hand:** needs a person to look.

## critical (3)

- **No credential is written literally in a configuration file** — `secrets` · `hcl.no-hardcoded-credentials`
  - Why: A key pasted into configuration is committed, mirrored to every fork and clone, and stays in history after it is deleted. Configuration files are read far more casually than application code.
  - Do: Move every credential to an environment variable or a secret store that the tool reads at run time, then rotate anything that was ever committed.
  - Verify: `grep -rnE '(secret|password|passwd|token|api_key|access_key|private_key)[a-z_]*[[:space:]]*=[[:space:]]*"[^"$]' --include='*.tf' --include='*.hcl' . 2>/dev/null | head -10`
  - Expect: No line assigns a literal secret. A variable reference, a data source, or a name that only points at a secret is fine.
  - Ref: https://developer.hashicorp.com/terraform/language/values/variables
- **State files are not tracked in git** — `secrets` · `hcl.terraform.state-not-committed`
  - Why: State holds every value the run produced in plain text, including database passwords and generated keys, whether or not the variable was marked sensitive. Committing it publishes them.
  - Do: Remove state from tracking, add it to gitignore, move to a remote backend, and rotate every credential the file ever held. Deleting the file does not remove it from history.
  - Verify: `git ls-files '*.tfstate' '*.tfstate.backup' '*.tfstate.*'`
  - Expect: Nothing is listed. Any output is a credential leak, not a tidiness problem.
  - Ref: https://developer.hashicorp.com/terraform/language/state/sensitive-data
- **No environment file is tracked in git** — `secrets` · `universal.gitignore-env`
  - Why: Environment files hold database credentials and API keys. Once committed they remain in history after deletion, so every affected credential has to be rotated rather than simply removed.
  - Do: Remove any tracked environment file from the index, add it to .gitignore, then rotate every credential that was ever committed.
  - Verify: `git ls-files --error-unmatch .env .env.local .env.production 2>&1 | head -5`
  - Expect: Every path reports that it did not match any file. A path echoed back is tracked and must be dealt with.
  - Ref: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html

## high (8)

- **The dependency lock file is committed** — `build` · `hcl.terraform.lock-file-committed`
  - Why: Without it every run may resolve a different provider version, so the plan you reviewed and the plan that applies can differ. Provider upgrades have destroyed resources this way.
  - Do: Commit the lock file and treat a change to it as a reviewable event rather than noise.
  - Verify: `git ls-files '.terraform.lock.hcl' '**/.terraform.lock.hcl'`
  - Expect: At least one lock file is tracked, for each directory that is applied.
  - Ref: https://developer.hashicorp.com/terraform/language/files/dependency-lock
- **Tool and provider versions are constrained** — `build` · `hcl.terraform.versions-pinned`
  - Why: An unconstrained provider picks up a major release on the next init, and a major provider release renames and replaces resources. The first sign is a plan that wants to destroy a database.
  - Do: Declare required_version for the tool and a version constraint for every provider in required_providers.
  - Verify: `grep -rn -A6 'required_providers\|required_version' --include='*.tf' . 2>/dev/null | head -20`
  - Expect: A required_version constraint, and a version line for each provider rather than a bare source.
  - Ref: https://developer.hashicorp.com/terraform/language/providers/requirements
- **The plan a human read is the plan that gets applied** — `reliability` · `hcl.terraform.plan-reviewed-before-apply`
  - Why: Applying without a saved plan re-plans at apply time, so what runs is not what was reviewed. Anything that changed in between, including a provider release, arrives unreviewed.
  - Do: Save the plan to a file, review that file, and apply the saved plan. In automation, make the saved plan the artifact that moves between the review step and the apply step.
  - Verify by hand: Look at how apply is invoked in CI or in the runbook, and confirm it applies a saved plan file rather than re-planning.
  - Ref: https://developer.hashicorp.com/terraform/cli/commands/plan
- **State lives in a remote backend that supports locking** — `reliability` · `hcl.terraform.remote-backend-with-locking`
  - Why: With local state, only one machine can apply and the file is one laptop failure from gone. Without locking, two applies at once interleave and leave state describing infrastructure that does not exist.
  - Do: Configure a remote backend that locks, and confirm locking is actually enabled rather than assumed. Migrate existing state rather than starting fresh.
  - Verify: `grep -rn -A4 'backend "' --include='*.tf' . 2>/dev/null | head -20`
  - Expect: A backend block naming a remote backend. No backend block at all means local state.
  - Ref: https://developer.hashicorp.com/terraform/language/backend
- **Resources that cannot be recreated are protected from destruction** — `reliability` · `hcl.terraform.stateful-resources-protected`
  - Why: A rename, a changed argument that forces replacement, or a wrong workspace turns one apply into data loss. Databases, buckets and volumes do not come back.
  - Do: Set prevent_destroy in a lifecycle block on every resource whose loss would be unrecoverable, and pair it with backups you have restored from at least once.
  - Verify: `grep -rn 'prevent_destroy' --include='*.tf' . 2>/dev/null | head -10`
  - Expect: Each database, bucket or volume in the configuration is covered. An empty result on a configuration with stateful resources is the finding.
  - Ref: https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle
- **A backup has been restored at least once** — `reliability` · `universal.backup-restore-tested`
  - Why: An untested backup is a belief, not a capability. Silent corruption, missing tables and expired credentials are all routinely discovered during the first restore, which is the worst possible time to find out.
  - Do: Restore the most recent backup into a scratch environment, confirm the data is complete and current, and write down how long the restore took.
  - Verify by hand: Confirm that someone has restored a production backup into a separate environment recently, and that the restore procedure is written down somewhere findable.
  - Ref: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
- **Variables holding secrets are marked sensitive** — `secrets` · `hcl.sensitive-variables-marked`
  - Why: Without the marking the value is printed in plan output and in CI logs, where it is kept far longer than anyone intends and is visible to everyone who can read the build.
  - Do: Set sensitive on every variable and output that carries a credential. It suppresses the value in plan and apply output.
  - Verify: `grep -rnE -A4 'variable "[^"]*(secret|password|token|key)[^"]*"' --include='*.tf' . 2>/dev/null | head -40`
  - Expect: Every declaration shown includes a sensitive line. One without it prints its value in plan output and in CI logs.
  - Ref: https://developer.hashicorp.com/terraform/language/values/variables
- **Variable files carrying real values are not committed** — `secrets` · `hcl.terraform.variable-files-not-committed`
  - Why: A variable file is where production credentials end up, and the automatically loaded ones are easy to commit by accident because nothing prompts for them.
  - Do: Keep committed variable files to non-secret defaults and an example file. Supply real values from the environment or a secret store.
  - Verify: `git ls-files '*.tfvars' '*.tfvars.json' | grep -v example | head -10`
  - Expect: Nothing beyond example files. Anything else needs its contents checked and probably rotated.
  - Ref: https://developer.hashicorp.com/terraform/language/values/variables

## recommended (2)

- **The working directory is not tracked in git** — `build` · `hcl.terraform.working-directory-not-committed`
  - Why: It holds downloaded provider binaries and a copy of the backend configuration, which can include a backend access key. It also makes every diff unreadable.
  - Do: Add the working directory to gitignore and remove it from tracking if it is already there.
  - Verify: `git ls-files '.terraform/*' | head -5`
  - Expect: Nothing is listed.
  - Ref: https://developer.hashicorp.com/terraform/cli/init
- **The project declares a license** — `legal` · `universal.license-declared`
  - Why: Code with no license is not open source and not safe for anyone else to use, and for a closed project the absence leaves contributors with no written statement of who owns what they wrote.
  - Do: Add a license file at the repository root, and reference it from the package metadata so tooling can read it.
  - Verify: `ls LICENSE LICENSE.md LICENSE.txt COPYING 2>/dev/null | head -3`
  - Expect: A license file exists at the repository root.

## optional (1)

- **Configuration is formatted with the tool's own formatter** — `build` · `hcl.formatting-consistent`
  - Why: Unformatted configuration makes review diffs noisy, and a noisy diff is where a one-line change to a security group goes unnoticed.
  - Do: Run the formatter over the tree and keep it in CI so the question never reaches review.
  - Operator-run (do not run this yourself): `terraform fmt -check -recursive`
  - Expect: The command lists no files. Anything listed is unformatted.
  - Ref: https://developer.hashicorp.com/terraform/cli/commands/fmt

---

Report what you found to the operator. Do not change the project.
