
Why Your Terraform State Is More Sensitive Than Your .env File
Terraform state can contain passwords, access keys, and a map of your infrastructure. Here is how I keep it out of Git, lock it in S3, and limit who can read it.

Terraform state can contain passwords, access keys, and a map of your infrastructure. Here is how I keep it out of Git, lock it in S3, and limit who can read it.
Most developers know not to commit a .env file. It goes into .gitignore, CI gets its own secrets, and anyone who pastes one into Slack gets a polite lecture.
Then the same team leaves terraform.tfstate on a laptop, copies it into a CI artifact, or gives every developer read access to the production state bucket.
That file deserves at least as much care as .env. Sometimes more.
Terraform state can contain database passwords, generated access keys, private key material, connection strings, and values returned by data sources. Even when there are no credentials in it, state is a detailed map of the account: resource names, ARNs, network IDs, endpoints, roles, and relationships.
The dangerous part is that Terraform makes sensitive values look safer than they are.
sensitive = true hides output, not dataConsider a normal sensitive variable:
variable "hrr_database_password" {
type = string
sensitive = true
}
resource "aws_db_instance" "hrr_app" {
identifier = "app-production"
username = "app"
password = var.hrr_database_password
# Other RDS settings omitted
}
Marking the variable as sensitive stops Terraform from printing its value in the usual CLI and HCP Terraform output. It does not automatically remove the value from state.
Terraform needs resource attributes so it can compare the configuration with the real resource later. The state snapshot is JSON, and many providers store sensitive arguments there. HashiCorp says this directly in its sensitive data documentation: sensitive values are redacted in output, while state access still needs to be controlled.
This distinction is easy to miss:
Those are different promises.
I treat a state snapshot as if it contains credentials, even when I think the configuration only creates harmless infrastructure.
That assumption holds up better than checking the file once and declaring it clean. Providers change. Modules add outputs. A resource that stored only an ARN last month may gain a password argument next month. Someone may read a secret through a data source while debugging and forget that Terraform recorded the result.
A production state file commonly exposes some mix of:
Not every resource stores every value. Provider schemas decide that. I don't use that uncertainty as a security boundary.
For AWS projects, I usually keep state in a dedicated S3 bucket. The bucket isn't shared with uploads, logs, or application assets. Its only job is holding Terraform state.
This is the backend configuration in the application stack:
terraform {
required_version = ">= 1.10.0"
backend "s3" {
bucket = "example-terraform-state"
key = "payments/production/terraform.tfstate"
region = "us-east-1"
encrypt = true
kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/REPLACE_ME"
use_lockfile = true
}
}
use_lockfile = true turns on native S3 state locking. It requires Terraform 1.10 or newer. Older setups often use a DynamoDB table for locking, but that mechanism is now deprecated. New projects don't need the extra table.
Locking is not encryption. It prevents two applies from writing the same state at once. Without it, two correct changes can still leave one broken state snapshot.
I create the backend bucket in a small bootstrap stack, separate from the application. The important parts look like this:
resource "aws_s3_bucket" "hrr_terraform_state" {
bucket = "example-terraform-state"
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "hrr_terraform_state" {
bucket = aws_s3_bucket.hrr_terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "hrr_terraform_state" {
bucket = aws_s3_bucket.hrr_terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.hrr_terraform_state.arn
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "hrr_terraform_state" {
bucket = aws_s3_bucket.hrr_terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_kms_key" "hrr_terraform_state" {
description = "Encrypt Terraform state"
enable_key_rotation = true
deletion_window_in_days = 30
lifecycle {
prevent_destroy = true
}
}
S3 already encrypts new objects with SSE-S3 by default. I use a customer-managed KMS key here because it gives me a second permission boundary and an audit trail for key usage. It also creates another failure mode: deleting or disabling the KMS key makes the state unreadable. That is why the key gets deletion protection too.
Versioning is the recovery path. If a bad apply or manual command overwrites state, an earlier object version is still available. State locking prevents concurrent writers; versioning helps after a bad write. I want both.
The most common mistake I see after moving state to S3 is a policy that gives the whole engineering team s3:GetObject on the bucket.
Remote doesn't mean secure. It only means the file moved.
My production CI role gets access to one state path, its lock file, and the matching KMS key. It does not get access to every application's state. A simplified S3 policy looks like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::example-terraform-state",
"Condition": {
"StringLike": {
"s3:prefix": ["payments/production/*"]
}
}
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": [
"arn:aws:s3:::example-terraform-state/payments/production/terraform.tfstate",
"arn:aws:s3:::example-terraform-state/payments/production/terraform.tfstate.tflock"
]
},
{
"Effect": "Allow",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::example-terraform-state/payments/production/terraform.tfstate.tflock"
}
]
}
The role also needs narrowly scoped kms:Encrypt, kms:Decrypt, kms:GenerateDataKey, and kms:DescribeKey permissions on the state key.
Notice that the policy can delete the lock file, but not the state object. Normal Terraform runs need to release locks. They do not need permission to delete production state.
I keep human write access out of the normal path. CI assumes the deployment role through short-lived credentials. A break-glass role can recover old versions, but that role isn't attached to anyone's everyday AWS session.
I don't put development and production in the same state just because Terraform workspaces make that convenient.
Workspaces separate state snapshots. They do not create separate IAM boundaries. If the same role can read the backend, a developer who needs development state may also be one policy mistake away from production state.
For small systems, separate prefixes plus precise IAM policies can be enough. For higher-risk systems, I use separate buckets or AWS accounts. The useful rule is simple: if two environments need different people or roles, give them different backend access boundaries.
I also avoid using terraform_remote_state as a general-purpose data API. A configuration may expose only selected outputs, but the caller's backend credentials can still read the full state snapshot. If another stack needs a VPC ID, I would rather publish that ID to SSM Parameter Store than grant access to the VPC's entire state.
Protecting the backend matters, but the best secret in state is the one that was never written there.
My first choice is letting AWS own the credential. For RDS, that can mean setting manage_master_user_password = true so RDS keeps the master password in Secrets Manager instead of passing a password through the normal Terraform argument.
Terraform 1.11 also added write-only resource arguments. The AWS provider supports password_wo on aws_db_instance, for example. Write-only arguments receive a value during the operation and omit it from plan and state files.
variable "hrr_database_password" {
type = string
sensitive = true
ephemeral = true
}
resource "aws_db_instance" "hrr_app" {
identifier = "app-production"
username = "app"
password_wo = var.hrr_database_password
password_wo_version = 1
# Other RDS settings omitted
}
This needs Terraform 1.11 or newer and a provider resource that supports the write-only argument. The version field is how Terraform knows that the unseen value should be updated.
Ephemeral and write-only values reduce what lands in new snapshots. They don't clean old versions. If a password existed in state before the change, I assume it still exists in S3 version history and rotate it.
A leaked .env file starts a credential rotation. A leaked state file should start the same process, plus an infrastructure review.
Deleting the current S3 object does not revoke a database password. Rewriting Git history does not revoke an IAM access key. Cleanup matters, but rotation closes the door.
use_lockfile = trueTerraform state is not a cache and it is not harmless deployment metadata. It is part of the production control plane. I secure it like a secrets store, back it up like a database, and restrict writes like a deployment system.
The .env file still belongs in .gitignore. Add *.tfstate* while you're there, then make sure the real state lives somewhere worthy of what is inside it.
Hope you enjoyed this one. If you want more practical AWS and Terraform notes, find me on X at https://x.com/harundotdev.
I email when a new post goes up. One send a week, and only if there's something new.
Related reading
Kept below the post instead of in a sidebar, with a slow continuous motion for a cleaner editorial feel.
On this post
Comments
A reply stays under the note it answers.
No comments yet.
If you have a note on Why Your Terraform State Is More Sensitive Than Your .env File, sign in and leave it.