Blog Field Notes terraform.tfstate, a Live VPN Key, and 100MB of Provider Binaries Committed on Day One
Platform #terraform#git#security#azure#aks

terraform.tfstate, a Live VPN Key, and 100MB of Provider Binaries Committed on Day One

Audited a six-month-old Terraform repo and found the state file, a live VPN pre-shared key, and all provider binaries committed in the initial push, then removed them from tracking and migrated state to an Azure Storage backend.

· Gideon Warui
ON THIS PAGE

The infrastructure was managed with Terraform from day one. The initial commit included everything in the working directory — which turned out to include things that should never have been committed. It was only when doing a general cleanup six months later that the full extent of what was in git history became clear.


What was tracked

git -C infra/ ls-files | sort
.terraform.lock.hcl
.terraform/modules/modules.json
.terraform/providers/registry.terraform.io/hashicorp/azuread/3.6.0/linux_amd64/terraform-provider-azuread_v3.6.0_x5
.terraform/providers/registry.terraform.io/hashicorp/azurerm/4.47.0/linux_amd64/terraform-provider-azurerm_v4.47.0_x5
.terraform/providers/registry.terraform.io/hashicorp/random/3.7.2/linux_amd64/terraform-provider-random_v3.7.2_x5
# ... more .terraform/ provider files
main.tf
modules/...
terraform.tfstate
terraform.tfstate.1759768772.backup
terraform.tfstate.backup
terraform.tfvars
variables.tf

Three problems:

1. .terraform/ provider binaries — compiled Go binaries, ~100MB. These are downloaded by terraform init from the Terraform registry. They change every provider version upgrade. They are platform-specific (this was linux_amd64; a macOS developer would need darwin_arm64). They have no business being in git.

2. terraform.tfstate — the live state of the infrastructure. Contains resource IDs, connection strings, and any sensitive = true output values that Terraform has produced. In this case, the state included Key Vault resource IDs and access policy bindings. State belongs in remote storage (Azure Storage backend), not in a repository.

3. terraform.tfvars — the variable values file. This one contained:

vpn_shared_key  = "<redacted-key>"
on_prem_public_ip = "<internal-ip>"

A live VPN pre-shared key, committed and pushed. It was in git history even after being removed from the working file.


Removing them from tracking

git rm --cached removes a file from git’s index without deleting it from disk. This stops it from being tracked going forward, but it does not rewrite history — the files still exist in prior commits.

# Remove state files
git -C infra/ rm --cached \
  terraform.tfstate \
  terraform.tfstate.backup \
  terraform.tfstate.1759768772.backup \
  terraform.tfvars \
  tfplan

# Remove provider binaries
git -C infra/ rm -r --cached .terraform/

After this, git status shows these files as deleted (staged), but they remain on disk. terraform init and terraform plan still work — the providers are still present locally.


The .gitignore

Without a .gitignore, removing them from tracking is temporary — a future git add . would re-add them. The .gitignore makes the exclusion permanent:

# Terraform state — use remote state (Azure Storage backend)
terraform.tfstate
terraform.tfstate.backup
terraform.tfstate.*.backup

# Plan files — environment-specific, not reproducible
tfplan
tfplan-*

# Provider downloads — restored by `terraform init`
.terraform/

# tfvars may contain secrets — document expected values in tfvars.example
terraform.tfvars

# Generated output files
terraform-outputs.json
terraform-outputs.txt

terraform.tfvars.example already existed in the repo with the variable names and safe defaults — that stays tracked. The actual values file does not.


The VPN key

The key <redacted-key> was committed in the initial push on October 1, 2025. Removing it from tracking today doesn’t remove it from the git object store — it’s still recoverable from the initial commit via git show or git log -p.

If this repository had ever been pushed to a remote with broader read access, the key would need to be rotated immediately. A git history rewrite (git filter-repo or BFG Repo Cleaner) would scrub it from the object store. For a private remote with controlled access, the risk is lower — but the key rotation is still the correct call.

# Rotate the VPN shared key in Azure
az network vpn-connection shared-key reset \
  --connection-name <connection-name> \
  --resource-group rg-<client>-payment-gateway-weu \
  --key-length 32

What was committed and why it should be remote state

terraform.tfstate was committed alongside the initial terraform apply. This is a common pattern when starting a new project — you run Terraform locally, everything works, you commit the result. What gets committed includes the state file because it’s in the working directory.

The problem: state files grow with every apply. They contain the current values of all resources Terraform manages. For AKS, this includes the cluster’s FQDN, the node pool IDs, the Key Vault URL, and any secrets Terraform was used to create. This is not information that should live in version control.

The correct setup is Azure Storage as the backend:

terraform {
  backend "azurerm" {
    resource_group_name  = "rg-<client>-payment-gateway-weu"
    storage_account_name = "st<client>terraform"
    container_name       = "tfstate"
    key                  = "<client>.tfstate"
  }
}

With a remote backend, terraform.tfstate is never created locally. State locking is handled by the storage backend. Multiple people can run Terraform without conflicts.

This was not set up at the start. Setting it up after the fact requires a terraform init -migrate-state to move the existing local state to the remote backend.


What stayed in the repo

After cleanup, the infra repo contains only what should be there:

infra/
├── .gitignore
├── .terraform.lock.hcl    ← lock file, should be committed
├── main.tf
├── variables.tf
├── outputs.tf
├── providers.tf
├── terraform.tfvars.example
├── kv-access-policy-admin.tf
├── kv-access-policy-aks-secrets-provider.tf
├── kv-access-policy-external.tf
├── modules/
│   ├── aks/
│   ├── key_vault/
│   ├── network/
│   └── ...
└── docs/

The .terraform.lock.hcl file stays — it pins provider versions and should be committed. terraform init uses it to ensure all contributors run the same provider versions.


Before the first git push

  1. Is terraform.tfstate in .gitignore? Yes.
  2. Is terraform.tfvars in .gitignore? Yes — use .tfvars.example as the template.
  3. Is .terraform/ in .gitignore? Yes — terraform init restores it.
  4. Is .terraform.lock.hcl tracked? Yes.
  5. Does any .tf file have a hardcoded secret? Check with grep -r 'password\|secret\|key' *.tf.
  6. Is the backend configured for remote state? Ideally yes, before first apply.
#terraform#git#security#azure#aks