A layout that scales keeps the logic in modules and makes each environment a thin root module: a backend, provider settings, one or two module calls and a tfvars file.
flowchart TB MOD["modules/app (v1.6.0)"] --> DEV["envs/dev: main.tf, dev.tfvars"] MOD --> STG["envs/staging"] MOD --> PRD["envs/prod (pinned v1.5.2)"] DEV --> BD["backend key dev/app.tfstate"] PRD --> BP["backend key prod/app.tfstate"] %% step MOD: The module holds the real logic and is released with version tags. %% step DEV: Dev uses the newest module version first and has small sizes in its tfvars. %% step STG: Staging follows, with the same code as prod will get. %% step PRD: Prod pins an older tag until the new one is proven, then bumps it in a pull request. %% step BP: Each environment has its own state and can have its own account and role.
infra/
modules/app/ # or a separate repo with tags
envs/
dev/ main.tf backend.tf dev.tfvars
staging/ main.tf backend.tf staging.tfvars
prod/ main.tf backend.tf prod.tfvars
# envs/prod/main.tf
module "app" {
source = "git::https://github.com/example-org/tf-modules.git//app?ref=v1.5.2"
env = "prod"
replicas = var.replicas
}
Run each environment from its folder (cd envs/prod) or with the global option terraform -chdir=envs/prod plan.
Variable precedence
When the same variable is set in several places, the last one in this list wins:
| Order | Source |
|---|---|
| 1 (lowest) | default in the variable block |
| 2 | Environment variable TF_VAR_replicas=2 |
| 3 | terraform.tfvars |
| 4 | terraform.tfvars.json |
| 5 | *.auto.tfvars / *.auto.tfvars.json, in alphabetical order |
| 6 (highest) | -var and -var-file on the command line, in the order you type them |
terraform.tfvars and *.auto.tfvars load automatically; any other file (prod.tfvars) needs -var-file. In CI, TF_VAR_ environment variables are handy for values that come from the pipeline, but never for long-lived secrets that end up in logs.
Keep environments honest
- Differences between environments should be values (sizes, counts, names), not different code paths.
- Avoid
if env == "prod"scattered through modules; pass a variable such asenable_backups. - Generate nothing by copy-paste: if three
main.tffiles drift apart, Terragrunt or a small wrapper (module 5) can keep the backend and provider boilerplate in one place.
Traps: an old terraform.tfvars in the folder silently overrides TF_VAR_ values; and -chdir must come before the subcommand (terraform -chdir=envs/dev plan, not terraform plan -chdir=...).