The previous post covered writing Terraform. This one covers running it safely as a team: where state lives, how changes are tested, and how a pipeline applies exactly what was reviewed.
Why state needs care
The state file maps each resource in your code to a real resource ID. It is also where Terraform keeps attribute values, which can include passwords and keys. That gives three rules:
- Store it remotely, so the whole team and the pipeline use the same one.
- Lock it, so two applies can never run at once and corrupt it.
- Protect it like a secret: encryption, versioning, tight access.
Architecture
flowchart LR
DEV[Engineer opens PR] --> CI[CI pipeline]
CI --> PLAN[fmt, validate, lint,<br/>scan, test, plan]
PLAN --> REV[Plan posted to PR<br/>human review]
REV --> MERGE[Merge to main]
MERGE --> APPLY[Apply saved plan<br/>after approval]
APPLY --> AWS[AWS account]
CI <--> STATE[S3 state bucket<br/>versioned, encrypted]
S3 backend with native locking
Since Terraform 1.10 the S3 backend can lock using a lock file stored next to the state, with S3 conditional writes. The DynamoDB lock table that older setups needed is no longer required.
# envs/prod/backend.tf
terraform {
backend "s3" {
bucket = "acme-tfstate-111122223333"
key = "shop/prod/terraform.tfstate"
region = "ap-south-1"
encrypt = true
use_lockfile = true
}
}
If you are migrating an existing backend that used dynamodb_table, you can set both during the transition, then remove the DynamoDB setting once everyone runs 1.10 or later.
Bootstrapping the state bucket
The bucket must exist before any backend can use it. Create it once, with its own tiny Terraform configuration that keeps local state (or with the CLI):
# bootstrap/main.tf
resource "aws_s3_bucket" "state" {
bucket = "acme-tfstate-111122223333"
lifecycle { prevent_destroy = true }
}
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.state.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
bucket = aws_s3_bucket.state.id
rule {
apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" }
}
}
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Versioning is your undo button: if state is ever corrupted, restore the previous object version.
One state per environment and layer
Split state so the blast radius of a mistake stays small:
| State key | Contains | Changes |
|---|---|---|
network/prod |
VPC, subnets, Transit Gateway | Rarely |
data/prod |
RDS, ElastiCache, S3 buckets | Occasionally |
app/prod |
ECS or EKS services, IAM roles | Often |
Read another layer’s outputs with terraform_remote_state, or better, look the resource up with a data source by tag, which avoids tight coupling between states.
Importing existing resources
Most teams start with things created by hand. Bring them under management with an import block, which is planned and reviewed like any other change:
import {
to = aws_s3_bucket.legacy_assets
id = "acme-legacy-assets"
}
Let Terraform write the starting configuration for you:
terraform plan -generate-config-out=generated.tf
Review generated.tf, tidy it into your normal files, and run plan again until it shows no changes. That confirms the code matches reality. Then delete the import block.
Testing
Static checks
terraform fmt -check -recursive
terraform validate
tflint --recursive # provider-aware lint, e.g. invalid instance types
trivy config --severity HIGH,CRITICAL . # misconfigurations such as public buckets
terraform test
terraform test runs .tftest.hcl files with assertions. A plan-mode test is fast and creates nothing:
# modules/network/tests/network.tftest.hcl
variables {
name = "test"
cidr = "10.99.0.0/16"
azs = ["ap-south-1a", "ap-south-1b", "ap-south-1c"]
}
run "creates_three_private_subnets" {
command = plan
assert {
condition = length(aws_subnet.private) == 3
error_message = "Expected one private subnet per AZ."
}
}
run "private_subnets_do_not_assign_public_ips" {
command = plan
assert {
condition = alltrue([for s in aws_subnet.private : s.map_public_ip_on_launch == false])
error_message = "Private subnets must not map public IPs."
}
}
cd modules/network && terraform init && terraform test
An apply-mode test (command = apply) creates real resources in a sandbox account and destroys them afterwards. Use it for modules where behaviour, not just the plan, matters.
The pipeline
Principles:
- Plans run on every pull request and are posted for review.
- Applies run only from
main, only the saved plan, and only after approval. - AWS access uses OIDC to assume a role. No access keys are stored anywhere.
- Plan and apply use different roles: read-only for plan, write for apply.
# .github/workflows/terraform.yml
name: terraform
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
permissions:
id-token: write
contents: read
pull-requests: write
env:
TF_IN_AUTOMATION: "true"
WORKDIR: infra/envs/prod
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra/envs/prod
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: "1.14.x" }
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/tf-plan
aws-region: ap-south-1
- run: terraform fmt -check -recursive
- run: terraform init -input=false
- run: terraform validate
- run: terraform plan -input=false -var-file=prod.tfvars -out=tfplan
- run: terraform show -no-color tfplan > plan.txt
- uses: actions/upload-artifact@v4
with: { name: tfplan, path: "${{ env.WORKDIR }}/tfplan" }
- name: Comment plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('${{ env.WORKDIR }}/plan.txt', 'utf8').slice(0, 60000);
github.rest.issues.createComment({
...context.repo, issue_number: context.issue.number,
body: '```\n' + plan + '\n```'
});
apply:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: plan
runs-on: ubuntu-latest
environment: production # requires a reviewer's approval
defaults:
run:
working-directory: infra/envs/prod
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: "1.14.x" }
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/tf-apply
aws-region: ap-south-1
- uses: actions/download-artifact@v4
with: { name: tfplan, path: "${{ env.WORKDIR }}" }
- run: terraform init -input=false
- run: terraform apply -input=false tfplan
The IAM role’s trust policy restricts who may assume it:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/infra:environment:production"
}
}
}]
}
Applying the saved plan matters: if anything changed between review and apply, Terraform refuses the stale plan instead of doing something nobody reviewed.
Detecting drift
Someone will change something in the console. A scheduled job catches it:
on:
schedule: [{ cron: "30 3 * * 1-5" }] # weekdays 09:00 IST
# ...same setup as the plan job, then:
- run: terraform plan -input=false -detailed-exitcode -var-file=prod.tfvars
-detailed-exitcode returns 2 when there are changes, which fails the job and alerts the team. Fix drift by updating the code or reverting the manual change, never by ignoring it.
Day-to-day rules
| Situation | Do |
|---|---|
| Stuck lock after a cancelled run | Confirm nothing is running, then terraform force-unlock <ID> |
| Need to remove a resource from Terraform without deleting it | removed block with lifecycle { destroy = false } |
| Rename or move a resource | moved block |
| Need to look at state | terraform state list and terraform state show <addr>; avoid editing state by hand |
| Provider upgrade | Separate pull request, read the changelog, plan every environment |
Summary
- S3 backend with
use_lockfile, versioning and encryption; one state per environment and layer. importblocks plus-generate-config-outto adopt existing resources.fmt,validate,tflint,trivy configandterraform teston every pull request.- Plan on PR, apply the saved plan on main after approval, OIDC roles split by plan and apply.
- A scheduled drift check.