Skip to content
Chapter 5Terraform1.x

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.

4 min read

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.