Secrets in Terraform: Vault Integration Without Leaking State
Terraform is bad at secrets. Not because it is poorly designed, but because secrets were never its job. Terraform manages infrastructure. Secrets belong to a secrets manager. The trouble starts when people treat Terrafor
Terraform is bad at secrets. Not because it is poorly designed, but because secrets were never its job. Terraform manages infrastructure. Secrets belong to a secrets manager. The trouble starts when people treat Terraform like a secrets store, because Terraform will happily comply, and then it will store every secret it touches in plain text in your state file.
This post covers how to integrate HashiCorp Vault with Terraform properly: dynamic secrets versus static reads, AppRole authentication for CI/CD, sensitive outputs, and the uncomfortable truth about what still leaks even when you do everything right.
The core problem
Three ways secrets leak in a typical Terraform workflow:
State files. Terraform state stores every attribute of every resource in plain text. Database passwords, API keys, connection strings. If you read Article 1, you already encrypt your state bucket and restrict IAM access. That is necessary. It is not sufficient, because anyone with read access to state can read every secret in it.
Variables and logs. Secrets passed as variables end up in plan output, in CI logs, and in shell history.
terraform plan -var="db_password=hunter2"is now in three places you do not control.Code. Secrets committed to
.tffiles or.tfvarsfiles end up in git history forever. Deleting the file later does not delete the commit.
Vault solves the storage problem. It does not automatically solve the state problem. You have to design for both.
The Vault provider
The Vault provider lets Terraform read secrets from Vault at plan and apply time, and lets you manage Vault resources (policies, mounts, roles) with Terraform itself. These are two different jobs. Keep them separate in your head.
Reading secrets (data sources):
terraform {
required_providers {
vault = {
source = "hashicorp/vault"
version = "~> 4.0"
}
}
}
provider "vault" {
# Address and auth configured via environment or AppRole (see below)
}
Managing Vault itself (resources):
resource "vault_mount" "db" {
path = "database"
type = "database"
}
resource "vault_policy" "app_reader" {
name = "app-reader"
policy = <<-EOT
path "secret/data/app/*" {
capabilities = ["read"]
}
EOT
}
One Terraform project manages Vault's configuration. Separate projects read secrets from Vault at runtime. Do not mix these in the same state file. The project that configures Vault needs admin-level access. The projects that read secrets should have the minimum Vault policy for their needs.
Static secrets vs dynamic secrets
Vault gives you two models. They solve different problems.
Static secrets are values someone wrote into Vault. An API key, a TLS certificate, a third-party token. Terraform reads them with a data source:
data "vault_kv_secret_v2" "api_key" {
mount = "secret"
name = "app/payments-api-key"
}
resource "aws_ssm_parameter" "payments_key" {
name = "/prod/payments/api-key"
type = "SecureString"
value = data.vault_kv_secret_v2.api_key.data["key"]
}
This is fine for secrets that change rarely. The weakness: Terraform stores the read value in state. You used Vault for storage, but the secret still lands in your state file in plain text. Vault did not fix the state problem. It just moved the secret one hop.
Dynamic secrets are credentials Vault generates on demand, with a time-to-live. This is the model you want for databases. Instead of storing a database password anywhere, Vault creates a temporary user, hands Terraform the credentials, and revokes them when the lease expires.
# Configure the database secrets engine (in your Vault-management project)
resource "vault_database_secret_backend_connection" "postgres" {
backend = vault_mount.db.path
name = "prod-postgres"
allowed_roles = ["app-role"]
postgresql {
connection_url = "postgresql://{{username}}:{{password}}@db.internal:5432/app"
username = var.vault_admin_user
password = var.vault_admin_password
}
}
resource "vault_database_secret_backend_role" "app" {
backend = vault_mount.db.path
name = "app-role"
db_name = vault_database_secret_backend_connection.postgres.name
creation_statements = [
"CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';",
"GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";",
]
default_ttl = 3600
max_ttl = 86400
}
Then the consuming project reads a fresh credential:
data "vault_database_secret_backend_static_role" "db_creds" {
backend = "database"
name = "app-role"
}
Dynamic credentials shrink the blast radius. A leaked credential expires within hours. There is no long-lived password to rotate, because there is no long-lived password. This is the single biggest upgrade you can make to database credential handling in Terraform.
Full example: RDS with Vault-managed credentials
Here is the pattern end to end. Terraform provisions the database. Vault manages who can talk to it. Terraform never stores a database password.
# 1. Provision the database (infrastructure project)
resource "aws_db_instance" "app" {
identifier = "app-prod"
engine = "postgres"
engine_version = "15.4"
instance_class = "db.r6g.large"
allocated_storage = 100
db_name = "app"
username = "admin"
# The admin password comes from Vault, set once at creation
password = data.vault_kv_secret_v2.db_admin.data["password"]
vpc_security_group_ids = [aws_security_group.db.id]
db_subnet_group_name = aws_db_subnet_group.main.name
backup_retention_period = 7
storage_encrypted = true
skip_final_snapshot = false
# Prevent accidental password churn on every apply
lifecycle {
ignore_changes = [password]
}
}
The ignore_changes on password matters. Without it, any drift in how Vault reports the secret triggers a password reset on every apply. You want the password set once at creation, then managed by your rotation process, not by Terraform diff noise.
# 2. Application gets dynamic credentials (separate, least-privilege)
data "vault_kv_secret_v2" "db_admin" {
mount = "secret"
name = "db/prod-admin"
}
# The app's Kubernetes secret uses a Vault-injected dynamic credential,
# not a Terraform-managed static password
resource "kubernetes_secret" "db" {
metadata {
name = "db-credentials"
namespace = "app"
}
data = {
# In production, prefer Vault Agent sidecar injection over
# Terraform-managed Kubernetes secrets. This example shows the
# Terraform path for completeness.
username = "app-service"
password = data.vault_kv_secret_v2.db_admin.data["password"]
}
type = "Opaque"
}
The boundary here is deliberate. Terraform provisions the database and the network around it. Vault owns credential lifecycle. The application authenticates through Vault, not through values Terraform wrote into state six months ago.
AppRole auth for CI/CD
The provider needs to authenticate to Vault. Hardcoding a token in CI/CD variables recreates the exact problem Vault was supposed to solve. Use AppRole instead.
AppRole has two parts: a RoleID (like a username, safe to embed in pipeline config) and a SecretID (like a password, delivered securely per run). The pipeline combines them to get a short-lived Vault token.
# Vault side: define the AppRole (in your Vault-management project)
resource "vault_auth_backend" "approle" {
type = "approle"
}
resource "vault_approle_auth_backend_role" "ci" {
backend = vault_auth_backend.approle.path
role_name = "terraform-ci"
token_policies = ["terraform-ci-policy"]
token_ttl = 600
token_max_ttl = 1800
}
# CI side: the Terraform project authenticates with AppRole
provider "vault" {
address = "https://vault.internal:8200"
auth_login {
path = "auth/approle/login"
parameters = {
role_id = var.vault_role_id
secret_id = var.vault_secret_id
}
}
}
In your pipeline, the RoleID lives in pipeline variables (it is not secret). The SecretID is injected per run from your CI system's secret store, or better, delivered through your cloud's native mechanism. On AWS, the pipeline role can fetch the SecretID from Secrets Manager at runtime. On GitHub Actions, use OIDC to Vault so there is no SecretID to manage at all.
The token TTL is 10 minutes. Even if it leaks into a log, it is dead before anyone can use it. Short TTLs are the whole point.
Sensitive outputs and plan redaction
Mark outputs that carry secrets as sensitive:
output "db_endpoint" {
value = aws_db_instance.app.endpoint
description = "Database endpoint (not secret)"
}
output "db_password" {
value = data.vault_kv_secret_v2.db_admin.data["password"]
sensitive = true
description = "Database admin password (secret)"
}
sensitive = true redacts the value from plan and apply output. It shows as (sensitive value) instead of the real string. This keeps secrets out of CI logs and terminal scrollback.
But understand what it does not do. Sensitive outputs are still stored in state in plain text. The redaction is display-only. It protects your logs, not your state file. Anyone with state read access sees the value.
Also note: sensitivity propagates. If a non-sensitive output derives from a sensitive value, Terraform marks the whole chain sensitive. This is correct behavior, but it can surprise you when half your outputs suddenly show as redacted. Trace the chain back to find the sensitive input.
What still leaks, honestly
Even with Vault, AppRole, and sensitive outputs, some things still leak. You should know exactly what.
State files remain a secrets surface. Any secret Terraform reads, even from Vault, lands in state in plain text. Data sources are not exempt. data.vault_kv_secret_v2.api_key puts the key in state. There is no way around this inside Terraform. The mitigations are the ones from Article 1: backend encryption, tight IAM on the state bucket, and minimal read access.
Plan files. terraform plan -out=plan.tfplan saves the plan to disk, including any non-redacted values. Do not archive plan files in artifact storage without thinking about what is inside them.
The real fix is architectural, not configurational. The less Terraform touches secrets directly, the less leaks. Prefer these patterns in order:
- Best: the application authenticates to Vault directly at runtime (Vault Agent sidecar, cloud auth). Terraform never sees the secret at all.
- Good: Terraform reads from Vault and passes to a managed secret store (AWS Secrets Manager, Azure Key Vault). The secret transits through state once, then lives in the right place.
- Acceptable: Terraform reads from Vault for one-time provisioning (initial admin password). Documented, audited, rotated after.
- Avoid: Terraform as the system of record for secrets. Static passwords in
.tfvars, checked into git, passed through five modules.
Most teams live at level 2 or 3. That is fine. Just know which level you are at.
Rotation: what happens when secrets change
Secrets rotate. Your Terraform needs to handle it without drama.
Vault-managed rotation: if Vault rotates a dynamic credential, the old lease expires and the application picks up new credentials on its next Vault read. Terraform does not need to do anything, because Terraform should not be holding the credential in the first place. This is the payoff of the dynamic model.
Static secret rotation: when someone rotates the API key in Vault, the next terraform plan shows a diff on every resource that consumed the old value. This is correct. Apply it, and the new value propagates. The risk is surprise: a rotation at 2 AM produces a diff nobody expected at 9 AM. Coordinate rotations with the team that owns the consuming Terraform.
Forcing re-reads: if Terraform is not picking up a rotated secret, the data source may be cached in state. terraform apply -refresh-only re-reads all data sources without changing infrastructure. Use it after rotations to confirm the new values flow through.
Tainting: if a resource was provisioned with a compromised credential, terraform taint marks it for recreation on the next apply. Taint the database, not the secret. The secret lives in Vault. Terraform recreates the infrastructure around the new credential.
The boundary
Here is the mental model I use. Draw a line:
- Terraform manages: infrastructure, Vault's own configuration (mounts, policies, roles, auth backends), IAM bindings, network.
- Vault manages: credential lifecycle, rotation, lease revocation, audit of who read what.
- The application manages: authenticating to Vault at runtime and using the credentials it gets.
Terraform configures the lock. Vault holds the keys. The application turns the handle. When each system stays in its lane, secrets stop leaking through the cracks between them.
Pre-flight checklist
- [ ] No secrets in
.tf,.tfvars, or git history (check withgit log -S 'password') - [ ] Vault provider configured; Vault-management project separated from secret-consuming projects
- [ ] Dynamic credentials for databases (database secrets engine with TTLs), not static passwords
- [ ] CI/CD authenticates via AppRole or OIDC, never a hardcoded token
- [ ] Vault tokens issued with short TTLs (minutes, not days)
- [ ] Outputs carrying secrets marked
sensitive = true - [ ] State backend encrypted, IAM restricted, versioning on (Article 1 checklist still applies)
- [ ] Rotation procedure documented: who rotates, who gets notified, what Terraform does after
- [ ] Team knows the architectural goal: Terraform touches secrets as little as possible
Closing
Vault does not make Terraform good at secrets. Nothing does. What Vault gives you is a clean boundary: secrets live in Vault, infrastructure lives in Terraform, and the two meet through short-lived, least-privilege reads. Dynamic credentials over static ones. AppRole over hardcoded tokens. And always, always assume the state file knows everything, because it does.
Next week is the finale: multi-cloud Terraform. One codebase, three clouds, and an honest look at when the abstraction is worth it and when it is not.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.