Variable Precedence & .tfvars: Who Wins the Override War?
When Multiple Sources Compete for the Same Variable
Terraform lets you set variable values from numerous sources simultaneously — defaults, files, command-line flags, environment variables. They don’t all have equal weight. There’s a strict precedence order, and knowing it explains why your config sometimes uses a value you didn’t expect.
This isn’t academic. In production, you’ll have a baseline terraform.tfvars with defaults, environment-specific files loaded via -var-file, secrets injected through TF_VAR_ environment variables from CI/CD, and possibly command-line overrides for one-off operations. All of these can define the same variable. Which one wins?
The Full Precedence Chain (Lowest to Highest Priority)
1. Default in variable block (lowest priority)
2. terraform.tfvars file (auto-loaded)
3. terraform.tfvars.json file (auto-loaded)
4. *.auto.tfvars files (alphabetical) (auto-loaded)
5. *.auto.tfvars.json files (auto-loaded)
6. -var-file flag on command line
7. -var flag on command line
8. TF_VAR_<name> environment variable (HIGHEST priority)
Each source higher in the stack overrides those below it. If terraform.tfvars sets region = "us-east-1" and you pass -var="region=eu-west-1", the command-line flag wins.
The chain makes intuitive sense when you think about it from a specificity perspective. Defaults are the most general — they apply when nobody says otherwise. File-based values are more specific. Command-line flags are even more specific (you’re explicitly overriding for this run). Environment variables are the most specific — they represent the runtime context of this particular execution.
The pairwise relationships worth internalizing:
| Source A | vs | Source B | Winner |
|---|---|---|---|
default in variable block | vs | terraform.tfvars | terraform.tfvars |
terraform.tfvars | vs | *.auto.tfvars | *.auto.tfvars |
*.auto.tfvars | vs | -var-file | -var-file |
-var-file | vs | -var | -var |
| Any file-based source | vs | TF_VAR_ | TF_VAR_ |
The Files Terraform Loads Automatically
Not all .tfvars files need to be explicitly specified. Some are loaded by convention:
| File | Auto-loaded? | Significance |
|---|---|---|
terraform.tfvars | ✅ Always | Your baseline defaults — loads without any flag |
terraform.tfvars.json | ✅ Always | JSON alternative to the above |
*.auto.tfvars | ✅ Always | Any file matching this glob auto-loads |
prod.tfvars | ❌ No | Must be passed with -var-file="prod.tfvars" |
secrets.tfvars | ❌ No | Must be explicitly specified |
The auto-loading behavior of *.auto.tfvars is a convenience that can become a footgun. If someone creates a file called debug.auto.tfvars to test something and forgets to delete it, those values silently load on every subsequent run. Be deliberate about naming — if a file should only load when explicitly specified, don’t give it the .auto.tfvars suffix.
.tfvars File Format
# terraform.tfvars
environment = "dev"
instance_type = "t3.micro"
allowed_cidrs = ["10.0.0.0/8", "192.168.1.0/24"]
db_config = {
name = "appdb"
port = 5432
username = "admin"
}
JSON format is also supported (.tfvars.json):
{
"environment": "dev",
"instance_type": "t3.micro"
}
The HCL format is more readable for humans. The JSON format is useful when .tfvars files are generated programmatically — by a CI/CD system, a wrapper script, or another tool in your pipeline.
The Multiple Environment Pattern
This is the standard approach for managing environment-specific configurations:
project/
├── main.tf
├── variables.tf
├── terraform.tfvars # Common baseline (auto-loaded)
├── envs/
│ ├── dev.tfvars # Dev overrides
│ ├── staging.tfvars # Staging overrides
│ └── prod.tfvars # Prod overrides
# Deploy to dev
terraform apply -var-file="envs/dev.tfvars"
# Deploy to prod
terraform apply -var-file="envs/prod.tfvars"
What happens: terraform.tfvars loads automatically and sets the baseline values. The explicit -var-file loads next and overrides anything it redefines. The result: baseline values where the environment doesn’t differ, overridden values where it does.
This pattern keeps environment differences explicit and auditable. You can diff dev.tfvars against prod.tfvars to see exactly what’s different between environments — instance sizes, replica counts, feature flags.
Environment Variables: TF_VAR_ for Secrets and CI/CD
Set a variable via shell environment:
export TF_VAR_db_password="s3cr3t-p@ssword"
export TF_VAR_environment="prod"
terraform apply
Terraform looks for environment variables prefixed with TF_VAR_ and maps them to variable names. TF_VAR_db_password corresponds to variable "db_password".
This is the standard pattern for CI/CD systems. Secrets — API keys, passwords, tokens — are configured as CI/CD environment variables. Terraform picks them up automatically. No secrets in source-controlled files. No secrets in command-line history.
# GitHub Actions example
env:
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
TF_VAR_aws_account_id: ${{ secrets.AWS_ACCOUNT_ID }}
The fact that TF_VAR_ has the highest precedence means pipeline-injected values always win, regardless of what’s in .tfvars files. This is by design — the runtime environment should have final say on secrets and context-specific values.
The .gitignore Strategy
# Never commit these
*.tfvars
*.tfvars.json
*.tfstate
*.tfstate.backup
.terraform/
# Always commit
.terraform.lock.hcl
The safest approach: treat all .tfvars files as potentially containing secrets and exclude them from Git. If you need baseline non-sensitive values shared across the team, use .tfvars.example files committed to Git as templates. Developers copy them and fill in real values locally.
Why not commit terraform.tfvars with non-sensitive defaults? Because the boundary between “sensitive” and “non-sensitive” shifts over time. An account ID might seem harmless until your threat model considers it a target indicator. A region name might seem safe until someone adds a VPN endpoint config to the same file. Excluding all .tfvars files and providing examples is the approach that ages well.
Design Scenario: The Secret That Appeared in the Logs
The problem: A developer declares variable "db_password" without sensitive = true. They set it with -var "db_password=hunter2". The plan output shows + password = "hunter2" in the terminal — and the CI/CD system captures that output in its logs. Now the database password is sitting in a log file accessible to anyone with CI/CD access.
The fix — three layers:
- Mark the variable sensitive:
variable "db_password" {
type = string
sensitive = true
}
Plan now shows + password = (sensitive value).
- Pass it via environment variable instead of command line:
export TF_VAR_db_password="hunter2"
terraform apply
# No password in shell history
- Use a secrets manager (Vault, AWS Secrets Manager) to avoid having the password in the Terraform workflow entirely.
Each layer reduces the exposure surface. Together, they make secrets handling robust.
Key Takeaways
The precedence chain exists so that more specific sources override more general ones. Understand this and you understand every “why is it using the wrong value?” debugging session. Check environment variables first (highest priority), then command-line flags, then files, then defaults.
terraform.tfvars auto-loads. *.auto.tfvars auto-loads. Everything else requires -var-file. If a variable has an unexpected value, check whether an auto-loaded file is overriding it.
Secrets belong in environment variables or a secrets manager — never in files that might be committed, shared, or logged. The sensitive = true flag suppresses output; it doesn’t encrypt anything. True protection requires encrypted state storage and runtime secret injection.

