Every terraform.tfstate file is a plaintext inventory of your infrastructure — database passwords, private keys, connection strings, and IAM credentials often sit right inside resource attributes in plain JSON. If that file lives unprotected on a laptop, in a public bucket, or in a CI artifact, a single leak can compromise your entire environment. This guide shows you how to encrypt a Terraform state file at rest and in transit, using a secure S3 remote backend, KMS-managed keys, and access controls that keep secrets out of reach of anyone who shouldn't see them. By the end, you'll have a remote backend with encryption enabled, state locking configured, IAM policies scoped to least privilege, and a workflow for verifying that nothing is exposed. We'll also cover common pitfalls — like accidentally committing local state or disabling encryption during a migration — so you can catch them before they become incidents.
Step 1: Understand Why Terraform State Needs Encryption
Terraform state tracks the real-world resources your configuration manages, including any sensitive values that pass through resource or data blocks — RDS master passwords, TLS private keys, API tokens generated by providers, and more. Terraform does not encrypt these values by default; they're written to disk as readable JSON. If you're still using local state (terraform.tfstate sitting in your project directory), stop and migrate to a remote backend before doing anything else. Local state is nearly impossible to secure consistently across a team, gets accidentally committed to git more often than anyone wants to admit, and offers no audit trail of who read or modified it.
The fix has two layers: encrypt the backend that stores your state, and tightly control who and what can access it. That's the core of terraform backend security, and it's the foundation for everything that follows.
Step 2: Encrypt Terraform State File Storage With an S3 Remote Backend
The most common production pattern is an S3 bucket with server-side encryption enabled, paired with DynamoDB for state locking. This is the terraform remote state s3 encryption setup most teams should standardize on.
First, create the bucket with encryption enforced:
resource "aws_s3_bucket" "tf_state" {
bucket = "your-org-terraform-state"
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.tf_state.arn
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_versioning" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Versioning matters as much as encryption here: if state is ever overwritten or corrupted, you need a way to roll back without losing track of resources.
Step 3: Use a Customer-Managed KMS Key Instead of the Default
Default S3 encryption (SSE-S3) is better than nothing, but a customer-managed KMS key gives you rotation control, granular IAM policies on the key itself, and CloudTrail visibility into every decrypt call — which matters a lot when the object being decrypted contains database passwords.
resource "aws_kms_key" "tf_state" {
description = "KMS key for Terraform state encryption"
deletion_window_in_days = 30
enable_key_rotation = true
}
resource "aws_kms_alias" "tf_state" {
name = "alias/terraform-state"
target_key_id = aws_kms_key.tf_state.key_id
}
Attach a key policy that only allows your CI role and designated operators to call kms:Decrypt and kms:GenerateDataKey. Anyone who can decrypt the key can read your secrets, so treat this key policy with the same rigor as a production database credential.
Step 4: Point Terraform at the Encrypted Backend
Wire the backend block into your root module. Note that the backend configuration itself cannot reference variables or resources defined in the same configuration — these values must be hardcoded or passed via -backend-config at init time.
terraform {
backend "s3" {
bucket = "your-org-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
kms_key_id = "alias/terraform-state"
}
}
The dynamodb_table entry enables state locking, which prevents two engineers (or two CI pipelines) from writing to state simultaneously and corrupting it. Create that table with a simple partition key named LockID:
resource "aws_dynamodb_table" "tf_locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
Run terraform init -migrate-state after adding this block to move any existing local or unencrypted state into the new backend.
Step 5: Lock Down Access With IAM, Not Just Encryption
Encryption protects data at rest, but it's only half the story — you still need to protect terraform secrets from anyone who has valid read access to the bucket and key. Scope IAM policies narrowly:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::your-org-terraform-state/prod/*",
"Condition": {
"StringEquals": { "s3:x-amz-server-side-encryption": "aws:kms" }
}
}
]
}
That Condition block is important: it rejects any PutObject call that doesn't specify KMS encryption, so a misconfigured client can't silently write an unencrypted object into the bucket. Pair this with a bucket policy that denies any request not using TLS (aws:SecureTransport = false), so state is encrypted in transit as well as at rest.
Step 6: Avoid Plaintext Secrets in State Wherever Possible
Encrypting the backend reduces blast radius, but the best defense is minimizing what ends up in state in the first place. Use sensitive = true on outputs and variables so values are redacted from CLI output (note this does not remove them from the state file itself). Where possible, generate secrets outside Terraform — in a secrets manager or vault — and reference them by ARN or path rather than passing raw values through resource arguments. For providers that must accept a plaintext credential (like an initial database password), consider a short-lived value that gets rotated immediately after creation via a separate process.
Verification and Troubleshooting
After migrating, confirm the setup actually works as intended:
- Confirm encryption is applied:
aws s3api head-object --bucket your-org-terraform-state --key prod/network/terraform.tfstateshould returnServerSideEncryption: aws:kmsand the correctSSEKMSKeyId. - Confirm public access is blocked:
aws s3api get-public-access-block --bucket your-org-terraform-stateshould show all four settings set totrue. - Confirm locking works: run
terraform planin two terminals against the same state; the second should fail with a "state locked" error rather than proceeding. - "Error acquiring the state lock" that persists after a crashed run usually means a stale DynamoDB entry. Verify no other process is actually running, then clear it with
terraform force-unlock <LOCK_ID>rather than deleting the table entry manually. - "Access Denied" on init or plan after tightening IAM usually means the KMS key policy wasn't updated alongside the S3 bucket policy — both need to grant the calling role access, not just one.
- State still readable as plaintext after "migration" typically means
-migrate-statecopied the file into the bucket before encryption was enabled, or a teammate is still pointed at an old backend config. Checkterraform.tfstate.backupfiles left behind locally and delete them once the migration is confirmed. - Periodically audit CloudTrail for
kms:Decryptevents against your state key — a spike in decrypt calls from an unfamiliar principal is often the first sign of a credential leak.
How Safeguard Helps
Getting the Terraform configuration right is necessary but not sufficient — teams still ship misconfigured backends, forgotten public buckets, and IAM policies that are broader than anyone realizes until an audit or an incident forces the question. Safeguard continuously scans your infrastructure-as-code and cloud environment for exactly these gaps: unencrypted state backends, S3 buckets missing public-access blocks, KMS key policies with excessive grants, and state files that drift out of sync with the access controls you thought you'd locked down.
Instead of relying on a one-time review, Safeguard's software supply chain security platform monitors your Terraform pipelines continuously, flags secrets that have leaked into state or version control history, and verifies that encryption and locking configurations stay enforced as your infrastructure evolves. For teams managing dozens of state files across multiple accounts, that continuous verification is what turns "we encrypted our state file once" into a durable security posture — catching the misconfigured backend or the overly permissive IAM policy before it becomes the root cause of an incident report.