Secrets Management in Terraform
Terraform configurations often need secrets — database passwords, API keys, TLS certificates. The challenge: secrets must be available at plan/apply time without being committed to version control, visible in CI logs, or stored in unencrypted state. This guide covers the most effective patterns used in production infrastructure.
The Core Problem
# NEVER DO THISresource "aws_db_instance" "main" { password = "SuperSecret123!" # Committed to git, leaked in state, shown in plan}
# ALSO WRONGvariable "db_password" { default = "SuperSecret123!" # Hardcoded default = hardcoded secret}The problems: version control history, CI/CD logs, and unencrypted state files can all expose the value.
Pattern 1: AWS Secrets Manager (Recommended for AWS)
Secrets live in AWS Secrets Manager, not in Terraform. Terraform reads them at runtime using a data source:
# Read a secret — value is never written to .tf files or tfvarsdata "aws_secretsmanager_secret_version" "db_password" { secret_id = "production/rds/master-password"}
resource "aws_db_instance" "main" { password = data.aws_secretsmanager_secret_version.db_password.secret_string}
# For JSON-encoded secrets (most common pattern)data "aws_secretsmanager_secret_version" "app_secrets" { secret_id = "production/app/credentials"}
locals { secrets = jsondecode(data.aws_secretsmanager_secret_version.app_secrets.secret_string)}
resource "aws_ecs_task_definition" "app" { container_definitions = jsonencode([{ name = "app" secrets = [ { name = "DATABASE_URL", valueFrom = "arn:aws:secretsmanager:..." }, { name = "API_KEY", valueFrom = "arn:aws:secretsmanager:..." } ] }])}Creating a secret with Terraform (rotation-friendly pattern):
resource "aws_secretsmanager_secret" "db_password" { name = "production/rds/master-password" recovery_window_in_days = 7}
resource "random_password" "db" { length = 32 special = true override_special = "!#$%&*()-_=+[]{}<>:?"}
resource "aws_secretsmanager_secret_version" "db_password" { secret_id = aws_secretsmanager_secret.db_password.id secret_string = random_password.db.result}
resource "aws_db_instance" "main" { password = random_password.db.result
lifecycle { ignore_changes = [password] # Let rotation handle updates }}Pattern 2: HashiCorp Vault
For multi-cloud or existing Vault infrastructure:
provider "vault" { address = "https://vault.mycompany.internal" # Auth via environment: VAULT_TOKEN or VAULT_ROLE_ID + VAULT_SECRET_ID}
# Read from KV v2data "vault_kv_secret_v2" "db_credentials" { mount = "secret" name = "production/database"}
resource "aws_db_instance" "main" { username = data.vault_kv_secret_v2.db_credentials.data["username"] password = data.vault_kv_secret_v2.db_credentials.data["password"]}
# Dynamic database credentials (Vault generates short-lived creds)data "vault_database_secret_backend_creds" "app" { backend = "database" role = "app-readonly"}
# Vault AWS secrets engine — generates temporary AWS credentialsdata "vault_aws_access_credentials" "deploy" { backend = "aws" role = "terraform-role" type = "sts"}Pattern 3: SOPS + Encrypted Files
SOPS encrypts secrets files that can be safely committed to version control. The file is encrypted with KMS, PGP, or age keys:
# Encrypt a secrets file with AWS KMSsops --kms "arn:aws:kms:us-east-1:123456789012:key/mrk-..." \ --encrypt secrets.yaml > secrets.enc.yaml
# Edit the encrypted file (SOPS decrypts in-place, re-encrypts on save)sops secrets.enc.yaml# Using the SOPS providerterraform { required_providers { sops = { source = "carlpett/sops" version = "~> 1.0" } }}
provider "sops" {}
data "sops_file" "production_secrets" { source_file = "secrets.enc.yaml"}
locals { db_password = data.sops_file.production_secrets.data["database_password"] api_key = data.sops_file.production_secrets.data["api_key"]}
resource "aws_db_instance" "main" { password = local.db_password}Pattern 4: OIDC — No Credentials at All
The best pattern for CI/CD: Terraform never receives a static secret. The CI runner proves its identity via OIDC and gets temporary credentials:
# GitHub Actions with OIDC (no AWS_ACCESS_KEY_ID needed)jobs: terraform: runs-on: ubuntu-latest permissions: id-token: write # Required for OIDC contents: read
steps: - uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole aws-region: us-east-1 # OIDC — no static secrets needed
- run: terraform apply -auto-approve# AWS IAM role that trusts GitHub Actions OIDCresource "aws_iam_role" "github_actions" { name = "GitHubActionsRole"
assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Federated = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/token.actions.githubusercontent.com" } Action = "sts:AssumeRoleWithWebIdentity" Condition = { StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" } StringLike = { "token.actions.githubusercontent.com:sub" = "repo:myorg/myrepo:*" } } }] })}Protect State — Secrets Still Land There
Even with the best secret sourcing patterns, secret values often end up in state (e.g., random_password.db.result). Encrypt and restrict access:
terraform { backend "s3" { bucket = "mycompany-terraform-state" key = "production/terraform.tfstate" region = "us-east-1" encrypt = true kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/mrk-..." dynamodb_table = "terraform-lock" }}# Restrict state bucket access to only Terraform IAM roleresource "aws_s3_bucket_policy" "state" { bucket = aws_s3_bucket.terraform_state.id
policy = jsonencode({ Statement = [{ Effect = "Deny" Principal = "*" Action = ["s3:GetObject", "s3:PutObject"] Resource = "${aws_s3_bucket.terraform_state.arn}/*" Condition = { ArnNotEquals = { "aws:PrincipalArn" = [ "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/TerraformRole" ] } } }] })}Quick Decision Guide
| Situation | Best Pattern |
|---|---|
| AWS infrastructure, secrets already in AWS | AWS Secrets Manager data source |
| Multi-cloud, existing Vault deployment | HashiCorp Vault provider |
| Secrets need to be in git (encrypted) | SOPS + terraform-sops provider |
| CI/CD runner identity (GitHub Actions, GitLab CI) | OIDC — no static credentials |
| Local dev only | .env file + TF_VAR_* environment variables |
| Quick prototype | -var flag (never commit the command) |