Skip to main content
Navigation
HomeTechnical ReferenceJournalGitHubGitHub
Sidebar — toggle document categories via the logo
Categories

Terraform Secrets Management

Overview

Secrets — database passwords, API keys, TLS certificates — must be handled carefully in Terraform. The state file and plan output can expose sensitive values if not properly managed. This reference covers strategies for minimizing secret exposure, from basic variable sensitivity to IAM-based authentication that eliminates secrets entirely.

The secret management problem

Terraform stores resource attributes in the state file. If you pass a database password through a variable, that password ends up in:

  1. terraform.tfstate — The state file contains resource attributes, including passwords.
  2. Plan outputterraform plan prints the planned changes, which may include sensitive values.
  3. Environment variablesTF_VAR_* values can leak through process listings and CI logs.

Each of these surfaces increases the attack footprint. The goal of secret management in Terraform is to minimize or eliminate plaintext secrets at every layer.

Strategy 1: Mark variables sensitive

The simplest mitigation — hide values from plan/apply output:

variable "db_password" {
description = "Database master password"
type = string
sensitive = true
}

resource "aws_db_instance" "mysql" {
# ...
password = var.db_password
}

What sensitive = true does:

  • Hides the value in terraform plan and terraform apply output.
  • Hides the value in terraform output (when output is also marked sensitive).
  • Does NOT prevent the value from being stored in the state file in plaintext.

Set via environment:

export TF_VAR_db_password="supersecret"
terraform apply

Limitation: The password is still stored in terraform.tfstate as a resource attribute. Anyone with access to the state file can read it.

Strategy 2: Encrypt state at rest

Always use a remote backend with encryption:

terraform {
backend "s3" {
bucket = "terraform-state"
key = "prod/terraform.tfstate"
encrypt = true # S3 server-side encryption
dynamodb_table = "terraform-lock"
}
}

And restrict access to the state bucket:

{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::terraform-state",
"arn:aws:s3:::terraform-state/*"
],
"Condition": {
"StringNotEquals": {
"aws:PrincipalArn": [
"arn:aws:iam::123456789:role/terraform-ci-role"
]
}
}
}

Strategy 3: KMS-encrypted secrets in files

Encrypt secrets with AWS KMS before they reach Terraform, storing only the ciphertext:

# Encrypt a secret (one-time setup)
aws kms encrypt \
--key-id alias/db-creds \
--plaintext fileb://db-creds.yml \
--output text \
--query CiphertextBlob > db-creds.yml.encrypted
# Read the encrypted file
data "aws_kms_secrets" "db" {
secret {
name = "db"
payload = file("${path.module}/db-creds.yml.encrypted")
}
}

# Decode and use
locals {
db_creds = yamldecode(data.aws_kms_secrets.db.plaintext["db"])
}

resource "aws_db_instance" "mysql" {
username = local.db_creds["username"]
password = local.db_creds["password"]
}

How it works:

  • Secrets are encrypted with KMS and stored as ciphertext in your repo.
  • Terraform uses the aws_kms_secrets data source to decrypt at plan/apply time.
  • Only principals with KMS kms:Decrypt permission can read the secrets.

Limitation: After decryption, the plaintext values appear in local values, which are stored in state. The state file still contains plaintext secrets.

Strategy 4: AWS Secrets Manager

Secrets Manager stores, rotates, and audits secrets — Terraform reads from it rather than holding the secret itself:

# Store the secret (one-time via CLI, outside Terraform)
aws secretsmanager create-secret \
--name db-creds \
--secret-string '{"username":"admin","password":"supersecret","host":"db.example.com"}'
# Read secret at plan/apply time
data "aws_secretsmanager_secret_version" "db" {
secret_id = "db-creds"
}

locals {
db_creds = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)
}

resource "aws_db_instance" "mysql" {
username = local.db_creds["username"]
password = local.db_creds["password"]
}

Advantages over KMS-encrypted files:

  • Secrets Manager handles automatic password rotation.
  • Integrated with CloudTrail for detailed audit logs.
  • Can use resource-based policies to restrict access.

Limitation: Like KMS, the decrypted values end up in state. Secrets Manager reduces but doesn't eliminate state exposure.

Strategy 5: Avoid secrets entirely with IAM roles

The best secret is no secret. For EC2 instances and other compute resources, use IAM roles instead of hardcoded credentials:

# IAM role for EC2
resource "aws_iam_role" "ec2_role" {
name = "ec2-app-role"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}

# Attach the policy that grants needed permissions
resource "aws_iam_role_policy" "app_permissions" {
name = "app-permissions"
role = aws_iam_role.ec2_role.id

policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:*", "dynamodb:*"]
Resource = ["*"]
}]
})
}

# Attach the role to an instance via an instance profile
resource "aws_iam_instance_profile" "app" {
name = "app-profile"
role = aws_iam_role.ec2_role.name
}

resource "aws_instance" "app" {
iam_instance_profile = aws_iam_instance_profile.app.name
# No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed
}

How it works:

  • The EC2 instance assumes the role automatically.
  • The AWS SDK on the instance uses instance metadata to get temporary credentials.
  • No long-lived credentials stored anywhere in Terraform configuration or state.

Strategy 6: OIDC for CI/CD

For CI/CD pipelines (GitHub Actions, GitLab CI), use OpenID Connect instead of long-lived IAM user credentials:

# Create the OIDC provider for GitHub Actions
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint]
}

# Fetch GitHub OIDC certificate thumbprint
data "tls_certificate" "github" {
url = "https://token.actions.githubusercontent.com"
}

# IAM role that GitHub Actions can assume
resource "aws_iam_role" "github_actions" {
name = "github-actions-terraform-role"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = [
for repo in var.allowed_repos : "repo:${repo.org}/${repo.repo}:*"
]
}
}
}]
})
}

GitHub Actions workflow:

jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-terraform-role
aws-region: us-east-2
- run: terraform apply

Why OIDC:

  • No long-lived AWS credentials stored in GitHub secrets.
  • Each repository gets short-lived tokens scoped to its specific needs.
  • Can restrict by repository, branch, or environment.

Best practices summary

PracticeEffect
sensitive = true on all password/secrets variablesHides values from plan output and logs.
encrypt = true on S3 backendState file encrypted at rest.
Restrict state bucket access via IAM policyOnly CI/CD roles and trusted admins can read state.
Use Secrets Manager or KMS for persistent secretsReduces plaintext copies in config files.
Use IAM roles for compute resourcesEliminates credentials from EC2 instances, Lambda functions, etc.
Use OIDC for CI/CDEliminates IAM user credentials from CI systems.
Never commit .tfvars with secrets to version controlUse environment variables or secrets managers instead.
Rotate secrets regularlySecrets Manager can automate rotation for RDS and other services.

See also