Secrets and Credential Management
The idea in one sentence
Section titled “The idea in one sentence”Provider authentication should come from a role or an identity mechanism rather than a literal key typed into a file, and even pulling an application secret from a real secrets manager at apply time does not fully keep it out of Terraform state.
Never hardcode credentials in a provider block
Section titled “Never hardcode credentials in a provider block”It is possible to authenticate the AWS provider with a literal access_key and secret_key written directly into the provider "aws" block or into a .tfvars file. It also works, in the narrow sense that terraform plan runs. It is still wrong:
# Anti-pattern — never do thisprovider "aws" { region = "us-east-1" access_key = "AKIAIOSFODNN7EXAMPLE" secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}A key written in a .tf file gets committed to git the first time someone forgets it is there, and it lives on in every clone and every commit in history from that point forward, whether or not it is later deleted from the current revision. The fix is to give the provider no credentials at all in configuration, and let it resolve them from the environment it is running in:
# The provider block declares no credentials.# Authentication comes from the environment: local env vars,# an EC2/CI instance role, or OIDC federation in CI.provider "aws" { region = "us-east-1"}Locally, that usually means environment variables or a named profile from aws configure. In CI, it means the OIDC-federated role assumption covered in the CI/CD lesson — aws-actions/configure-aws-credentials populates the standard AWS environment variables before Terraform ever runs, so the provider block finds credentials without a single key ever being written down anywhere.
Pulling a secret in via a data source, not a variable
Section titled “Pulling a secret in via a data source, not a variable”A separate question from provider authentication is application secrets — a database password, a third-party API key — that a resource needs as an argument. Defining that secret as a Terraform variable makes the variable declaration the thing you have to treat as the source of truth, which means someone still has to type the real value into a tfvars file, a CI secret, or a prompt at apply time.
The better pattern reads the current value directly from a real secrets manager through a data source, at apply time:
data "aws_secretsmanager_secret_version" "api_key" { secret_id = "prod/third-party/api-key"}
resource "aws_lambda_function" "webhook_handler" { function_name = "webhook-handler" runtime = "nodejs20.x" handler = "index.handler"
environment { variables = { API_KEY = data.aws_secretsmanager_secret_version.api_key.secret_string } }}This is not a free pass, and it is worth being precise about why. The State Management module already covered that marking a variable sensitive = true only redacts it from CLI output — the value still lands in terraform.tfstate in plaintext. Reading a secret through a data source does not fully avoid that either: data source results are cached in state too, so the resolved secret_string still ends up recorded there, the same as a sensitive variable would. What the data-source pattern actually improves is the secret’s lifecycle everywhere else: the real value is never typed into a variable, never sits in a .tfvars file someone might commit by accident, and rotating it in Secrets Manager needs no Terraform change at all — the next apply simply reads whatever is current. Secrets Manager is the source of truth; Terraform just reads from it. Protecting the state backend, covered in the State Management module, is still the part that cannot be skipped.
A .gitignore checklist for a Terraform/Terragrunt repository
Section titled “A .gitignore checklist for a Terraform/Terragrunt repository”A short, deliberate .gitignore prevents most of the accidents above from ever reaching git in the first place:
# Terraform / Terragrunt .gitignore
# Never commit state — it can contain plaintext secrets and is the# canonical source of truth; committing it invites drift and leaks.*.tfstate*.tfstate.backup
# Local provider and module cache, regenerated by `terraform init`..terraform/
# May contain sensitive values depending on the file — judge case by# case rather than blanket-ignoring every *.tfvars in the repo.*.tfvars
# Terragrunt's local working-directory cache, regenerated automatically..terragrunt-cache/A few of these deserve a one-line reason each:
*.tfstateand*.tfstate.backup— never commit state. Beyond the size and merge-conflict problems, state can contain plaintext secrets, as covered above, and it duplicates the job a remote backend already does properly..terraform/— this isinit’s local cache of downloaded providers and modules. It is large, machine-specific, and fully reproducible from.terraform.lock.hcl, so there is nothing worth versioning here.*.tfvars— treat this one case by case rather than blanket-ignoring it. Some teams deliberately commit a non-sensitiveenvironment.tfvarsthat just sets instance sizes or region names; the rule is “does this specific file contain a secret,” not “all.tfvarsfiles are always secret.”.terragrunt-cache/— Terragrunt’s equivalent of.terraform/, one per unit, regenerated on every run. It never needs to be committed, and committing it by accident tends to bloat a repository fast.
flowchart LR bad["Hardcoded credential in a .tf / .tfvars file"] -->|committed by accident| leak["Leaked secret in git history"] sm["Secrets Manager: source of truth"] -->|data source at apply time| attr["Resource attribute"] attr -->|still recorded| state["terraform.tfstate"]