Infrastructure as Code Part 1 of 3 · Infrastructure as Code Security
Secure Terraform Foundations: Pinning, State, Secrets and Review
The security foundations of a Terraform repository: pinned versions with a committed lock file, remote state with locking and access control, secrets kept out of source, least-privilege plan and apply roles.
On this page
Overview
Terraform turns an infrastructure change into a text diff, which is what makes it reviewable. The same property makes a Terraform repository a high-value target: a change that opens a security group or grants a policy is one approved merge request away from production, and the state file behind it contains the real values of everything the configuration created. The foundations below are the habits that keep that diff trustworthy before any scanner is involved. Part 2 adds the scanner; part 3 puts it in the merge request.
Terraform commands in this part were run with the hashicorp/terraform:1.14 image (Terraform 1.14.9) against
the configuration from part 2. plan and apply against a real account were not run; where a behaviour depends
on that, the text says so.
Prerequisites
- Terraform basics: resources, variables, outputs,
planandapply - Docker, to run Terraform from its image, or Terraform 1.1x installed
- A Git repository to keep the configuration in
Pin everything that can move
A Terraform run has three moving inputs: the Terraform binary, the providers, and any modules. Unpinned, each can change between two runs of the same commit, and a provider release can change resource defaults (what a bucket’s ACL means, what a security group allows) without a line of your code changing.
terraform {
required_version = "~> 1.14"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "eu-west-1"
}
~> 6.0 allows 6.x but not 7.0. The exact version is then recorded by terraform init in the dependency lock
file, and that file is what makes the choice reproducible:
docker run --rm -v "$PWD:/tf" -w /tf hashicorp/terraform:1.14 init -backend=false -input=false
Terraform has been successfully initialized!
provider "registry.terraform.io/hashicorp/aws" {
version = "6.64.0"
constraints = "~> 6.0"
hashes = [
# one h1: and several zh: checksums per platform
]
}
Commit .terraform.lock.hcl. With it, every init on every machine selects 6.64.0 and verifies the provider
package against the recorded checksums; an upgrade is a deliberate terraform init -upgrade in its own merge
request. Without it, the runner picks whatever 6.x is newest today.
Modules get the same treatment. Registry modules take a version constraint; Git sources take a ref, and a tag can be moved, so a commit SHA is the strict option:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.21"
}
module "internal_baseline" {
source = "git::https://gitlab.example.com/platform/tf-baseline.git?ref=3f2c1d9e4b7a6c5d8e9f0a1b2c3d4e5f6a7b8c9d"
}
A module is code that runs with your credentials. Treat adding one like adding a dependency: read what it creates, pin it, and prefer modules your organisation owns or has reviewed over the most-starred one.
State is sensitive
The state file records every attribute of every resource Terraform manages, including ones the configuration never wrote down: generated passwords, private keys returned by a provider, endpoint addresses, account IDs. Two consequences follow.
State never goes in Git. Even a private repository is cloned to laptops and CI runners. Add it to .gitignore
along with the other files that carry secrets or machine-specific paths:
# Terraform state and workspace
*.tfstate
*.tfstate.*
.terraform/
crash.log
# Variable files with real values (commit *.tfvars.example instead)
*.tfvars
*.tfvars.json
!*.auto.tfvars.example
# Saved plans contain the same values state does
*.tfplan
.terraform.lock.hcl is deliberately not in that list; it contains checksums, not secrets, and must be committed.
State lives in a remote backend with locking and access control. For AWS that is an S3 bucket with versioning, encryption and a public-access block (the same controls part 2 will make Checkov enforce), and, from Terraform 1.10, native locking on the bucket itself:
terraform {
backend "s3" {
bucket = "example-terraform-state"
key = "network/prod/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true # S3-native locking (Terraform 1.10+); no DynamoDB table needed
}
}
Access to that bucket is access to every secret in the state. Grant it to the CI role and to the few people who run Terraform by hand, and to nobody else; separate the plan role (read state) from the apply role (read and write state) as described below. This backend block was not initialised in these runs because no AWS account was used; the syntax is from the Terraform 1.14 documentation.
Keep secrets out of source, and where possible out of state
Three layers, in order of preference.
Do not have the secret at all. Many providers can generate and manage a credential so that it never appears in configuration, variables or (for some resources) state. For RDS:
resource "aws_db_instance" "app" {
# ...
manage_master_user_password = true # AWS generates and rotates it in Secrets Manager
}
The application reads the password from Secrets Manager at runtime with its own IAM role. Nothing in the repository or the state file knows it.
Pass it in, marked sensitive. When a value must be supplied, declare it sensitive so Terraform redacts it
from plan and apply output and from terraform output:
variable "api_token" {
description = "Token for the monitoring provider"
type = string
sensitive = true
}
Supply it from the environment (TF_VAR_api_token) or a CI variable, never from a committed .tfvars. Note what
sensitive does not do: the value is still stored in state in plain text, and a resource that uses it may echo it
into its own attributes. Redaction is a display property. Terraform 1.10 added ephemeral values and 1.11 added
write-only resource arguments to keep a value out of state entirely; the provider must support them for the
resource in question, and they were not exercised here.
Reference it, do not copy it. For values that already live in a secret manager, read them with a data source at plan time rather than pasting them into variables. The data source result still lands in state, so this is a convenience for rotation, not a fix for state exposure; the fix for state exposure is the backend access control above.
Least privilege for the credentials Terraform runs with
Terraform can only do what its credentials allow, and “AdministratorAccess on the CI role” is the most common way
a small mistake in a .tf file becomes a large one. Split the roles by what each step needs:
| Step | Needs | Runs on |
|---|---|---|
fmt, validate, Checkov | Nothing. No credentials, no network beyond the provider download. | Every merge request |
plan | Read the state; describe resources (ec2:Describe*, s3:Get*, rds:Describe*) | Every merge request |
apply | Read and write the state; create, update, delete the resources in scope | Protected branch only, after review |
Give the plan role read-only permissions and the apply role a policy scoped to the services this repository manages. Prefer short-lived credentials (OIDC from the CI platform to an IAM role) to long-lived keys stored as CI variables; a leaked static key is valid until someone notices, a leaked OIDC token expires in minutes.
Without credentials at all, plan stops at the provider, which is the behaviour a merge request from a fork should
get:
docker run --rm -v "$PWD:/tf" -w /tf hashicorp/terraform:1.14 plan -input=false \
-var office_cidr=203.0.113.0/24 -var vpc_id=vpc-0123456789abcdef0
Planning failed. Terraform encountered an error while generating this plan.
│ Error: No valid credential sources found
Repository hygiene and review workflow
The review is the control. What reaches the reviewer decides what they can catch.
Format and validate on every change. fmt -check fails on unformatted files so that diffs show intent, not
whitespace; validate catches wrong attribute names and type errors before a plan is attempted. Both ran clean on
the part 2 configuration:
docker run --rm -v "$PWD:/tf" -w /tf hashicorp/terraform:1.14 fmt -check -diff
docker run --rm -v "$PWD:/tf" -w /tf hashicorp/terraform:1.14 validate
Success! The configuration is valid.
Plan in the merge request, apply from the protected branch. The plan output is the review artifact. Post it
(or attach it) to the merge request so the reviewer reads + resource, ~ update in-place and, above all,
-/+ destroy and then create replacement lines, not just HCL. Save the plan and apply that file, so the apply
cannot differ from what was reviewed:
terraform plan -input=false -out=tfplan
terraform show -no-color tfplan > plan.txt # attach to the merge request
# after merge, on main:
terraform apply -input=false tfplan
A saved plan is only valid against the state it was made from; if someone applied in between, apply refuses and
you plan again. That is the correct outcome.
Protect main and require review. One approver who did not write the change, no direct pushes, and apply
only from a pipeline on that branch. This is the same shape as the
CI/CD Engineering path uses for application deploys, for the same reason: exactly
one way for a change to reach production.
Keep the repository small and named. One repository (or one root module directory) per blast radius: network and accounts separately from application infrastructure, production separately from staging. A state file that holds everything is a state file whose apply role can change everything.
Security Considerations
- A provider is code from the registry that runs with your credentials. The lock file’s checksums verify the download; the version constraint decides which code you accept. Keep both under review.
terraform destroyand a plan with replacements are the destructive operations. Require a plan review for both and considerprevent_destroylifecycle rules on stateful resources.- Local Terraform runs with personal credentials bypass every control above. Make the pipeline the fast path so nobody needs the slow one.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
init selects a different provider version on the runner than locally | Lock file not committed | Commit .terraform.lock.hcl; run terraform providers lock for the runner’s platform |
Error: Inconsistent dependency lock file | Constraint changed without updating the lock | terraform init -upgrade in a dedicated merge request |
plan shows a sensitive value in clear | Variable not marked sensitive, or value surfaced by a resource attribute | Mark it; move the secret to a managed/ephemeral mechanism |
apply refuses a saved plan | State changed since the plan was made | Plan again; this is the safeguard working |
| Two pipelines apply at once and corrupt state | No state locking | Enable use_lockfile (S3) or the backend’s locking mechanism; serialise apply jobs |
Production Recommendations
- Pin Terraform, providers and modules; commit the lock file; upgrade deliberately.
- Remote, encrypted, versioned, locked state with access granted to roles, not people.
- Prefer provider-managed and ephemeral secrets; mark the rest
sensitive; never commit.tfvarswith values. - Separate plan and apply credentials; use OIDC; apply only from the protected branch, from a saved plan.
- Split state by blast radius.
Conclusion
None of this is a scanner. It is the shape of a repository whose diffs can be trusted: pinned inputs, protected state, secrets that are not in the diff, credentials that cannot do more than the change needs, and a review that sees the plan. Part 2 adds the tool that reads those diffs for the mistakes a human reviewer will miss.
References
Keep reading