Secrets in Terraform: Never Hardcode, Never Regret

Secrets in Terraform: Never Hardcode, Never Regret
The Fastest Way to Get Paged at 2am
# DO NOT DO THIS
resource "aws_db_instance" "main" {
  password = "SuperSecret123!"
}

That password is now in your .tf file. It’s in your Git history — forever, even if you delete the line. It’s in your terraform.tfstate file in plaintext. It’s visible to anyone who runs terraform plan. You’ve turned a secret into a public announcement with a permanent paper trail.

Terraform touches secrets constantly — database passwords, API keys, TLS private keys, OAuth tokens, service account credentials. Getting secrets handling right isn’t a nice-to-have. It’s the difference between a secure production deployment and a breach waiting to happen.

The challenge is fundamental to how Terraform works: Terraform needs secrets to create resources (it must pass the password to the database API), and the state file records everything the API returns. This means secrets always end up in state. You can control how they get into Terraform and how they appear in output, but you cannot prevent them from being stored in state. Accepting this shapes every decision about secrets management.

Layer 1: The sensitive Flag

Mark variables and outputs as sensitive:

variable "db_password" {
  type      = string
  sensitive = true
}

variable "api_key" {
  type      = string
  sensitive = true
}

What sensitive = true does:
– Redacts the value in plan and apply output: + password = (sensitive value)
– Propagates sensitivity through modules — child modules that receive this value automatically treat it as sensitive
– Any output that references a sensitive value is also automatically marked sensitive

What sensitive = true does NOT do:
– It does NOT encrypt the value in the state file — it’s still plaintext JSON in terraform.tfstate
– It does NOT prevent the value from being passed to the cloud API — the cloud still receives the actual password
– It does NOT prevent someone from extracting the value via terraform output -json

The sensitive flag is output suppression, not encryption. It prevents the password from appearing in terminal output, CI/CD logs, and PR comments — all valuable. But it doesn’t solve the underlying storage problem. That requires encrypting state.

Layer 2: Environment Variables — The CI/CD Standard

The cleanest injection method for automated pipelines:

export TF_VAR_db_password="${{ secrets.DB_PASSWORD }}"
export TF_VAR_api_key="${{ secrets.API_KEY }}"

terraform apply

The value never appears in a file. It’s not in shell history (when set from a secrets manager). It’s not in the Terraform config. It exists only in the process’s environment and evaporates when the pipeline finishes.

# GitHub Actions
- name: Terraform Apply
  env:
    TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
    TF_VAR_api_key: ${{ secrets.API_KEY }}
  run: terraform apply -auto-approve

This approach leverages the pipeline’s built-in secrets storage, which is purpose-built for this problem — encrypted at rest, access-controlled, audit-logged, and never exposed in output.

Layer 3: HashiCorp Vault — Enterprise Secrets Management

For organizations with centralized secrets management, pull secrets directly from Vault at plan time:

provider "vault" {
  address = "https://vault.company.com:8200"
  # Auth: via VAULT_TOKEN env var, AppRole, or cloud IAM auth
}

data "vault_generic_secret" "db" {
  path = "secret/prod/database"
}

resource "aws_db_instance" "main" {
  identifier = "prod-db"
  engine     = "postgres"
  username   = data.vault_generic_secret.db.data["username"]
  password   = data.vault_generic_secret.db.data["password"]
}

The credentials are stored in Vault with full audit logging, pulled fresh on every apply, and never written to .tf files. They still end up in state — that’s unavoidable with Terraform’s current architecture — but Vault provides centralized rotation, access policies, and audit trails.

For production Vault deployments, use AppRole or IAM-based authentication — not static tokens. A static Vault token is itself a secret you need to manage, creating a chicken-and-egg problem.

Layer 4: Cloud-Native Secrets Managers

Use your cloud’s native secret store as a data source — no Vault infrastructure required:

# AWS Secrets Manager
data "aws_secretsmanager_secret_version" "db_password" {
  secret_id = "prod/database/password"
}

resource "aws_db_instance" "main" {
  password = data.aws_secretsmanager_secret_version.db_password.secret_string
}
# GCP Secret Manager
data "google_secret_manager_secret_version" "db_password" {
  secret  = "prod-db-password"
  version = "latest"
}
# Azure Key Vault
data "azurerm_key_vault_secret" "db_password" {
  name         = "prod-db-password"
  key_vault_id = data.azurerm_key_vault.main.id
}

This approach is simpler than Vault for teams already committed to a single cloud provider. The secrets management infrastructure is already there — you just reference it.

Layer 5: HCP Terraform’s Encrypted Variable Store

In HCP Terraform (HashiCorp’s hosted platform), you can store sensitive variables directly in the workspace:

  • Mark a variable as “sensitive” in the UI or API
  • HCP Terraform never re-displays the value after entry
  • The value is available to remote runs but never shown in UI or logs
  • Managed with team-based access controls

This is the simplest option when using HCP Terraform — no Vault infrastructure, no environment variable coordination.

Sensitive Outputs — Controlling What’s Exposed

If a resource produces a sensitive value in an output, mark it:

output "db_connection_string" {
  value     = "postgres://${aws_db_instance.main.username}:${var.db_password}@${aws_db_instance.main.endpoint}/appdb"
  sensitive = true
}
$ terraform output
db_connection_string = <sensitive>

$ terraform output -json db_connection_string
# Reveals the value — the user is explicitly requesting it

A critical nuance: terraform output -json does reveal sensitive values. This is intentional — the user is explicitly requesting programmatic access to the output. But it means you should be careful about scripts or pipelines that call terraform output -json and log the result.

The Secrets Handling Strategy
WhatHow to Protect
Database passwordssensitive = true + env vars or secrets manager
API keyssensitive = true + env vars
TLS private keysVault or cloud-native secrets manager as data source
.tfvars filesNever commit to Git; use .gitignore
State fileRemote backend with encryption at rest + strict IAM
Outputs with secretssensitive = true
CI/CD secretsPipeline’s built-in secret store → TF_VAR_ env vars
.gitignore — The Absolute Minimum
# Never commit
*.tfvars
*.tfvars.json
terraform.tfstate
terraform.tfstate.backup
.terraform/

# Always commit
.terraform.lock.hcl

Treat all .tfvars files as potentially containing secrets. Provide .tfvars.example files committed to Git as templates — developers copy and fill in real values locally.

Key Takeaways

Secrets always end up in the Terraform state file in plaintext. This is by design — Terraform needs to track the full state of your resources, including passwords and keys. You can’t avoid it. What you can control is: how you inject secrets into Terraform (environment variables, Vault, cloud secrets managers) and how you protect the state file (encrypted remote backend, strict IAM, audit logging).

The sensitive flag prevents exposure in terminal output and CI/CD logs. It does not encrypt the state file. These are two separate concerns, and both need to be addressed independently.

Start simple: environment variables + encrypted remote state covers 90% of use cases. Add Vault or cloud-native secrets managers when you need centralized rotation, audit logging, or dynamic credentials. Don’t over-engineer secrets management for a dev environment — but don’t under-engineer it for production either.