The Terraform workflow that scales
The commands you will run daily, how to structure multiple environments without copy-paste, and what a sane CI pipeline for infrastructure looks like.
The daily commands
terraform fmt -recursive # canonical formatting
terraform validate # syntax and types, no API calls
terraform plan # what would change
terraform apply # do it
terraform output # read the outputs
terraform show # human-readable current state
fmt and validate cost nothing and belong in a pre-commit hook. plan is the one you will run
dozens of times a day.
Targeting and refreshing
terraform plan -target=aws_s3_bucket.site # plan one resource
terraform apply -refresh-only # sync state with reality, change nothing
terraform plan -refresh=false # skip the refresh — much faster on big configs
State operations
terraform state list # every managed address
terraform state show aws_s3_bucket.site # one resource's attributes
terraform state mv aws_s3_bucket.old aws_s3_bucket.new # rename without recreating
terraform state rm aws_s3_bucket.site # stop managing it (does not delete it)
terraform import aws_s3_bucket.site my-bucket-name # start managing something that exists
state mv is the one that saves you. Renaming a resource in your code makes Terraform see a
destroy plus a create — because the address changed. state mv updates the mapping instead, and
the plan comes back empty.
Import blocks — the modern way
Since 1.5, imports can be declared in code and planned before they happen:
import {
to = aws_s3_bucket.legacy
id = "my-existing-bucket"
}
terraform plan -generate-config-out=generated.tf
Terraform writes the resource block for you. Much better than the old imperative
terraform import, because you can review it first.
Structuring multiple environments
This is where most Terraform codebases go wrong. Three approaches, in increasing order of how well they hold up.
Workspaces — convenient, limited
terraform workspace new staging
terraform workspace select staging
terraform apply
One configuration, separate state per workspace. Fine when environments are genuinely identical
and differ only in a few variables. It breaks down as soon as prod needs a resource that dev does
not, and the count = terraform.workspace == "prod" ? 1 : 0 conditionals start spreading.
Directory per environment — verbose but obvious
environments/
├── dev/
│ ├── main.tf
│ └── terraform.tfvars
├── staging/
└── prod/
modules/
├── network/
└── application/
Each environment has its own backend key, its own state, its own explicit configuration. It is more files, and the blast radius of a mistake is one environment. This is what most teams converge on.
# environments/prod/main.tf
module "application" {
source = "../../modules/application"
environment = "prod"
instance_count = 6
instance_type = "t3.large"
}
Terragrunt — if the repetition gets painful
Terragrunt is a thin wrapper that removes the duplicated backend and provider blocks across environments. Worth considering at roughly ten environments or more; unnecessary below that.
Writing your own modules
A module is a directory with the same three files:
modules/application/
├── main.tf
├── variables.tf
├── outputs.tf
└── README.md
Two rules make modules good:
Take inputs, do not read the environment. A module that calls data "aws_vpc" "default" is
only usable in accounts where that default exists. A module that takes vpc_id as a variable
works everywhere.
Output everything a caller might need. The ARN, the id, the endpoint, the security group. Outputs are cheap and a missing one means editing the module.
variable "vpc_id" {
description = "VPC the application runs in"
type = string
}
output "security_group_id" {
description = "Security group attached to the application"
value = aws_security_group.app.id
}
CI for infrastructure
The pattern that works:
name: Terraform
on:
pull_request:
push:
branches: [main]
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- run: terraform init
- run: terraform fmt -check -recursive
- run: terraform validate
- run: terraform plan -out=tfplan -no-color
# Post the plan on the PR so a human reads it before merge
- uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const plan = require('fs').readFileSync('plan.txt', 'utf8');
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '```\n' + plan.slice(0, 60000) + '\n```',
});
The essential properties: plan on the pull request, apply only on merge to main, and never store long-lived cloud credentials — use OIDC.
Security scanning
brew install tfsec checkov
tfsec .
checkov -d .
Both catch the common misconfigurations — public buckets, unencrypted volumes, security groups open to the world — before they reach an account. Adding one of them to CI takes ten minutes.
Next: the mistakes that cost people databases.