Managing Secrets
Secrets — database passwords, API keys, TLS private keys, OAuth client secrets — are the most dangerous values to mishandle in Infrastructure as Code. Terraform configuration is usually committed to version control and shared across a team, so anything hardcoded into a .tf file is effectively public to everyone with repository access (and anyone who later clones the history). This page covers how to keep secrets out of your source, inject them at runtime from a dedicated secret store, and deal with the uncomfortable truth that secrets almost always end up in Terraform state.
Never hardcode secrets
The first rule is the simplest: a literal secret should never appear in a .tf file, a terraform.tfvars committed to git, or a default value in a variable block. The following is the anti-pattern to avoid.
# DO NOT DO THIS — secret is now in git history forever
resource "aws_db_instance" "main" {
identifier = "app-prod"
engine = "postgres"
username = "appuser"
password = "S3cr3t-Passw0rd!" # leaked the moment you commit
instance_class = "db.t3.medium"
allocated_storage = 20
}
Even if you later delete the line, the value persists in git history and must be treated as compromised and rotated. Instead, declare the secret as a sensitive variable with no default and supply it from outside the codebase.
variable "db_password" {
description = "Master password for the application database"
type = string
sensitive = true
}
resource "aws_db_instance" "main" {
identifier = "app-prod"
engine = "postgres"
username = "appuser"
password = var.db_password
instance_class = "db.t3.medium"
allocated_storage = 20
}
Marking a variable sensitive = true tells Terraform to redact it from CLI output and plan logs. It does not remove it from state — more on that below.
Inject secrets at runtime
With no default value, Terraform must obtain db_password from somewhere at plan/apply time. The cleanest sources are environment variables and CI/CD secret stores, never a committed file.
# Environment variable: TF_VAR_<name> maps to var.<name>
export TF_VAR_db_password="$(op read 'op://infra/prod-db/password')"
terraform apply
In CI, the value comes from the pipeline’s encrypted secret store (GitHub Actions secrets, GitLab CI variables, etc.) and is exposed only as TF_VAR_db_password for the duration of the job. This keeps the secret out of the repo and out of any file on disk.
The data-source pattern
A more robust approach is to never hand Terraform the raw secret at all — instead, store it in a managed secret service and have Terraform read it through a data source. Terraform fetches the value at runtime, uses it, and you never type it into a variable.
AWS Secrets Manager
data "aws_secretsmanager_secret" "db" {
name = "prod/app/db"
}
data "aws_secretsmanager_secret_version" "db" {
secret_id = data.aws_secretsmanager_secret.db.id
}
resource "aws_db_instance" "main" {
identifier = "app-prod"
engine = "postgres"
username = "appuser"
password = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)["password"]
instance_class = "db.t3.medium"
allocated_storage = 20
}
The secret_string here holds JSON like {"username":"appuser","password":"..."}, so jsondecode(...) extracts a single field. This works identically under OpenTofu, since the AWS provider is the same.
SSM Parameter Store
For lower-cost or simpler use cases, AWS Systems Manager Parameter Store works the same way. Use a SecureString parameter and let the provider decrypt it.
data "aws_ssm_parameter" "db_password" {
name = "/prod/app/db_password"
with_decryption = true
}
resource "aws_db_instance" "main" {
identifier = "app-prod"
engine = "postgres"
username = "appuser"
password = data.aws_ssm_parameter.db_password.value
instance_class = "db.t3.medium"
allocated_storage = 20
}
Comparing secret sources
| Source | Where the secret lives | Audit & rotation | Best for |
|---|---|---|---|
TF_VAR_* env var | CI secret store / shell | Manual, in CI | Bootstrapping, simple pipelines |
| AWS Secrets Manager | Managed AWS service | Automatic rotation, CloudTrail | Production app credentials |
| SSM Parameter Store | Managed AWS service | CloudTrail, manual rotation | Config + lightweight secrets |
| HashiCorp Vault | Vault cluster | Dynamic, short-lived secrets | Multi-cloud, dynamic credentials |
Secrets still land in state
This is the critical gotcha. Any secret a resource consumes — whether injected via a variable or read through a data source — is written in plaintext into the Terraform state file. The aws_db_instance.password, the decoded Secrets Manager value, and the SSM parameter value all appear unencrypted in terraform.tfstate.
Output:
$ terraform show -json | jq '.values.root_module.resources[]
| select(.address=="aws_db_instance.main") | .values.password'
"S3cr3t-Passw0rd!"
Warning: A
sensitivevariable is redacted from CLI output but is NOT encrypted in state. Treat the state file itself as a secret. Anyone who can read your state can read every credential it touched.
Because of this, you must use a backend that encrypts state at rest and restricts access — for example an S3 backend with SSE-KMS and a locking mechanism. The data-source pattern reduces the blast radius (secrets are not in git) but does not keep them out of state; protecting state is a separate, mandatory control covered in the sensitive-state page.
Best Practices
- Never commit a literal secret; declare it as a
sensitivevariable with no default value. - Inject secrets via
TF_VAR_*environment variables from a CI secret store, never from a committedtfvarsfile. - Prefer the data-source pattern (Secrets Manager, SSM, Vault) so Terraform reads secrets from a managed, auditable store at runtime.
- Always encrypt remote state at rest and lock down read access — secrets land in state regardless of how they were supplied.
- Rotate any secret that has ever been committed to git or printed to a log; consider it permanently compromised.
- Add
*.tfvarsand*.tfstate*to.gitignore, and scan commits for leaked secrets in CI before they merge. - Use dynamic, short-lived credentials from Vault where supported to shrink the window of exposure.