Git Secrets and Terraform Project Hygiene
Terraform code belongs in Git. Terraform state generally does not. Secrets definitely should not be casually committed. The difficult part is knowing where each kind of information belongs and understanding what Terraform’s sensitive mechanism actually protects. This chapter establishes practical repository hygiene for a team Terraform project.
What Belongs in Git?
Terraform configuration is code.
It should normally be version-controlled:
terraform/
├── main.tf
├── variables.tf
├── outputs.tf
├── providers.tf
├── modules/
└── README.mdGit gives the team:
- History
- Code review
- Branches
- Pull requests
- Reproducibility
- Change ownership
- Collaboration
But Terraform also creates local files that should not be committed.
What Should Not Be Committed?
A typical .gitignore should exclude Terraform’s working directory and state files.
For example:
.terraform/
*.tfstate
*.tfstate.*
crash.log
crash.*.log
*.tfvars
*.tfvars.jsonBe careful with the last two patterns.
Not every .tfvars file is necessarily secret, but variable files frequently contain environment-specific values or credentials. A safer team convention is to keep private variable files out of Git unless you have a deliberate reason to version them.
You should generally commit:
.terraform.lock.hclThe dependency lock file records selected provider versions and checksums and should normally be reviewed alongside Terraform configuration.
A Sensible Repository
A small project might look like:
terraform/
├── .gitignore
├── .terraform.lock.hcl
├── providers.tf
├── main.tf
├── variables.tf
├── outputs.tf
└── README.mdThe repository should not look like:
terraform/
├── terraform.tfstate
├── terraform.tfstate.backup
├── .terraform/
├── production.tfvars
├── aws-passwords.tfvars
└── main.tfThe second structure is an accident waiting to happen.
Why State Must Stay Out of Git
State can contain values returned by providers, including values that are sensitive.
Putting state in Git creates several problems:
- Every Git clone may contain the state
- Old commits preserve deleted secrets
- Forks and mirrors may copy it
- Pull requests can expose it
- Access to the repository becomes access to state history
- Removing a file from the latest commit does not remove it from Git history
A remote backend is therefore not just a convenience.
It gives state a separate lifecycle and access-control boundary from the source repository.
Git
│
└── Terraform Configuration
Backend
│
└── Terraform StateWhat About .terraform/?
The .terraform/ directory is Terraform’s local working data.
It can contain:
- Downloaded provider packages
- Module data
- Backend initialization information
- Other Terraform working files
You normally regenerate it with:
terraform initTherefore, it should not be committed.
Use:
.terraform/in .gitignore.
Why Commit .terraform.lock.hcl?
The lock file is different.
It is intended to be shared with the team.
When you initialize Terraform, provider selections and checksums are recorded in:
.terraform.lock.hclCommit this file so different machines and CI environments can use the same locked provider selections and verify provider packages consistently.
The general rule is:
.terraform/
└── generated working data → ignore
.terraform.lock.hcl
└── dependency selection data → commitSensitive Values in Terraform
Suppose a variable contains a password:
variable "database_password" {
type = string
sensitive = true
}The sensitive flag tells Terraform to treat the value as sensitive when displaying it.
For example, Terraform can avoid printing the actual value in normal plan or apply output.
That is useful.
But it is not secret management.
Sensitive Does Not Mean Secret Storage
This distinction is critical:
sensitive = true
│
└── controls how Terraform displays a value
Secret management
│
└── controls where the secret is stored,
who can access it, and how it is deliveredA sensitive value can still exist in Terraform state.
If you provide a password to Terraform and a resource requires that password as part of its managed attributes, Terraform may need to record it in state.
Therefore:
Marking a variable as sensitive does not magically remove the value from state.
It primarily protects against accidental disclosure through Terraform’s normal UI and command output.
Example
Consider:
variable "database_password" {
type = string
sensitive = true
}
output "database_password" {
value = var.database_password
sensitive = true
}Terraform can suppress the actual output.
But the password still exists as Terraform data.
That means you must also protect:
- State
- Backend access
- CI logs
- Variable storage
- Local environments
- Secret-management systems
Where Should Secrets Come From?
For local development, a secret might be supplied through an environment variable:
export TF_VAR_database_password='example-password'Terraform automatically maps:
TF_VAR_database_passwordto:
var.database_passwordThe important advantage is that the secret does not have to be written into the .tf file.
But environment variables are not a complete secret-management platform either.
They can still be exposed through:
- Shell history mistakes
- Process environments
- CI configuration
- Debug output
- Local machine compromise
For production, organizations often use a dedicated secret-management system and inject secrets into Terraform through controlled mechanisms.
Examples include cloud secret stores or organizational secret managers.
The specific system is less important here than the principle:
Secret storage and access should be handled by a system designed for secrets.
Provider Credentials
Do not write provider credentials into Terraform source:
provider "aws" {
access_key = "AKIA..."
secret_key = "..."
}That creates an obvious source-control problem.
Use the provider’s normal credential chain instead.
For local AWS usage, that might come from the AWS CLI credential configuration or environment variables.
For CI, the preferred modern pattern is often short-lived or federated credentials (generally OIDC) rather than a permanent access key stored as a CI variable/secret.
That topic becomes important in the next chapter.
Secret Leakage Through Git History
Imagine someone accidentally commits:
terraform.tfvarscontaining a password.
Then they notice the mistake and delete it in the next commit.
The secret is still in Git history.
Commit A ── secret added
│
Commit B ── secret deletedThe repository still contains Commit A.
If the repository has been cloned, mirrored, cached, or forked, the secret may exist in multiple places.
The correct response is not merely:
“Delete the file.”
You should:
- Revoke or rotate the exposed secret.
- Determine where it may have been exposed.
- Remove the secret from repository history if appropriate.
- Verify that the replacement secret is not present in history.
- Review access logs where practical.
The most important step is rotation.
Once a credential has been committed, assume it is compromised.
A Practical .gitignore
A small Terraform repository can begin with:
# Terraform working directory
.terraform/
# Terraform state
*.tfstate
*.tfstate.*
# Crash logs
crash.log
crash.*.log
# Variable files that may contain secrets
*.tfvars
*.tfvars.jsonYou may choose to allow specific non-secret example files if your project needs them.
For example:
!examples/example.tfvarsBut only do this when you are certain the example contains no real credentials.
Repository Hygiene Is About Boundaries
A good repository separates:
Version-controlled
│
├── Terraform configuration
├── module code
├── documentation
└── provider lock file
Not version-controlled
│
├── state
├── Terraform working directory
├── credentials
└── private variable filesAnd the backend separately protects:
Terraform StateThis separation reduces the blast radius when something goes wrong.
A Final Secret Checklist
Before committing Terraform code, ask:
- Does this contain a password?
- Does this contain an API token?
- Does this contain cloud credentials?
- Does this contain a private key?
- Does this contain a production endpoint that should be private?
- Does this file belong in the repository?
- Could the value still exist in Git history?
Then ask a second question:
If this value appears in Terraform state, who can read that state?
That question is often more important than whether the value is hidden from terraform plan.
What Comes Next?
Git gives the team a shared source of truth for configuration, but it does not execute Terraform safely by itself.
Eventually someone needs to run:
terraform fmt
terraform validate
terraform plan
terraform applyDoing that manually from laptops works for a while.
For a production team, the next step is to move those operations into a controlled CI/CD workflow.