skip to content

Across a Terraform estate with many root modules, how do you decide which variable values are committed as tfvars files and which are injected at run time, and what goes wrong when a laptop and CI disagree?

level: principalimportance: nice to knowfreq 28%

answer

  1. repository is the source of truth
  2. inject only secrets and per-run identifiers
  3. values in a shell profile diverge silently
  4. one wrapper command for humans and CI
  5. log injected names, never secret values

basics

~20 s

Commit everything that should be reviewable and identical for everyone — sizes, CIDRs, environment names — and inject only secrets and genuinely per-run identifiers. Values that live in one engineer's shell make plans differ by machine, with no error to warn anyone.

solid answer

~50 s

My default rule is that the repository is the source of truth and run-time injection is the exception. Committed per-environment `-var-file` files carry everything a reviewer should be able to see: sizes, CIDRs, counts, environment and account names. Injection is reserved for two categories — secrets, delivered as `TF_VAR_` from the pipeline's secret store, and identifiers the pipeline itself produces, such as a release version. Nothing else. The failure this prevents is the value that exists only in somebody's shell profile: their plan and CI's plan differ, both are green, and no error is raised because Terraform is happy to take a value from any tier. I also make humans and CI run the same wrapper command so the inputs cannot diverge by habit, and record the injected variable *names* in the job log so an apply can be explained afterwards.

go deeper

for a junior

Understand the distinction being drawn: some values live in a committed tfvars file that everyone shares, others are supplied at run time, and secrets are never committed.

for a middle

Explain how each channel behaves — a committed file is reviewable and identical for everyone, while TF_VAR_ is per-shell and outranked by any tfvars file — and why that makes the environment tier unreliable for standing configuration.

for a senior

Describe the concrete failures you are preventing: a plan that differs between laptop and CI with no error, and an apply nobody can explain a month later. Name the wrapper command and the saved plan as the mechanisms.

for a principal

Set the policy for the whole estate and enforce it mechanically: which classes of value may occupy which tier, a check that injected names never appear in committed files, and an audit trail that makes every applied input reconstructable.

## The question behind the question Terraform will take a value from six different places. That flexibility is fine for one root module and corrosive across thirty, because "where does this value come from" stops having a single answer and starts having a per-repository archaeology. The decision worth making explicitly is a *policy* about which tier each class of value is allowed to occupy. ## A workable default policy **Committed, in a per-environment file passed with `-var-file`:** environment and account names, region, CIDR blocks, instance sizes and counts, retention periods, feature toggles. These are configuration decisions. They belong in a file because they should be diffed in a pull request, blamed in `git log`, and identical whoever runs the command. **Injected at run time:** secrets, from the pipeline's secret store into `TF_VAR_<name>`; and identifiers the pipeline produces, such as a build or image version. Both are genuinely per-run, and neither can be reviewed in advance because neither exists until the run starts. **Nothing else.** In particular, no standing configuration in an engineer's shell profile, and no ad-hoc `-var` in a job definition that quietly redefines what an environment is. ## Why the environment tier is the dangerous one `TF_VAR_` ranks below every tfvars file, above only the default. That has two consequences worth designing around. First, a committed `*.auto.tfvars` silently overrides anything the pipeline exports, so injected variable names must never appear in a committed file — worth enforcing with a small repository check, because the failure is a green run with the wrong value rather than an error. Second, an exported value that exists on one machine and not another produces plans that differ with no visible cause; the diff is real, both runs succeed, and the disagreement surfaces later as drift or as an apply that undoes someone else's change. ## Making machines agree The practical mechanism is that nobody types a raw `terraform` command. A wrapper — a `Makefile` target, a small script — assembles the same invocation for humans and for CI: ```bash # make plan ENV=prod terraform -chdir=envs/$(ENV) plan \ -input=false \ -var-file=$(ENV).tfvars \ -out=tfplan ``` Everything the run depends on is now visible in one place and version-controlled. A developer who wants a different value edits the file and opens a pull request, which is exactly the behaviour you want. Combined with `-input=false` and required variables that carry no default, an incomplete invocation fails loudly instead of falling back to something plausible. ## Auditability: explaining an apply after the fact A month later someone asks why production got a particular value. You want the answer to come from the repository plus the job log, not from reconstructing a shell. That means: log the full Terraform command line, log the *names* of injected variables (never the values of secrets), and use a saved plan file so the apply consumes exactly what the plan computed rather than re-resolving variables at apply time. If a `-var` override was used during an incident, it should be visible in the log and followed by a pull request that makes it permanent or removes it. ## The tradeoff you are actually making Committing more values buys reviewability, reproducibility and a single answer to "what will this deploy". It costs a pull request for every change, including trivial ones, and it means values must be safe to store in the repository. Injecting more buys flexibility and keeps secrets out of version control, at the price of runs that cannot be explained by reading the code and of laptop-versus-CI divergence. Most estates should sit far towards the committed end and treat every exception as something that has to be justified and named. The clearest signal that a repository has drifted the other way is a `README` explaining which environment variables you must export before running `terraform plan` — at that point the configuration lives in a wiki, not in the code. ## What an interviewer is listening for Not the tier list — that is the middle-tier answer. They want to hear that you have a rule, that the rule is enforced by something other than discipline, and that you can name the specific failure each part of it prevents: the silent override, the machine-dependent plan, the unexplainable apply.

  • How do you prove afterwards which values produced a given apply?
    Record the full Terraform command line and the names of injected variables in the job log — names only, never secret values — and have `apply` consume a saved plan file produced by the earlier `plan`, so the applied inputs are exactly the ones that were reviewed rather than re-resolved at apply time. Combined with per-environment files in version control, the answer becomes a commit plus a job log rather than an archaeology exercise.
  • Where do values shared by every environment belong?
    Either a `common.tfvars` passed first on the command line, with the per-environment file after it so overrides are explicit, or as defaults in the shared module the environments call. The file version keeps everything visible at the root and makes the layering obvious in the command; the module-default version reduces duplication but hides the value one level down. I prefer the explicit file when the estate is small enough to read, and module defaults once the number of root modules makes duplication the bigger risk.
  • An engineer insists on exporting TF_VAR_ values in their shell profile because it saves typing. What is your objection?
    Their plan is no longer the same plan CI produces, and nothing will tell either of them. Because TF_VAR_ sits below the tfvars tier it is also unreliable — a committed auto-loaded file will override it without warning, so the habit fails silently in both directions. The typing is better saved by a wrapper command that everyone shares, which produces the identical invocation and keeps the values in version control.

saying these in an interview costs you the question

  • Treats shell-exported variables as normal per-developer configuration
  • Commits secrets to a tfvars file for reviewability
  • Uses ad-hoc -var overrides as the routine way to run environments
  • Assumes an identical repository guarantees an identical plan
  • Documents required exports in a README instead of fixing the invocation

context