Infrastructure as code · Advanced
Terraform in a team: state, modules, environments and CI
Why it matters
One engineer running Terraform from a laptop works until two people apply at once, a laptop holds the only copy of state, or a change meant for staging hits production. Platform and senior cloud roles are judged on the workflow that prevents this. Interviewers ask how you organise Terraform across teams and environments.
The concept
Remote state with locking: store state in a backend (an S3 bucket, Terraform Cloud, Azure Storage, Google Cloud Storage) that is encrypted, versioned, access-controlled and locked during runs, so two applies cannot collide.
Modules: reusable building blocks with inputs and outputs (a "service" module that creates a bucket, role and alarms). Version them.
Environments: separate state per environment, usually separate directories or workspaces calling the same modules with different inputs. Production often lives in a separate cloud account.
Plan in CI: every pull request runs
fmt,validate, a security scan andplan, and posts the plan for review. Apply happens only from the main branch, through the pipeline, with approval for production.Drift detection: a scheduled plan that alerts when reality differs from code.
Policy as code: rules checked against plans (no public buckets, required tags) with tools such as OPA, Checkov or Sentinel.
Worked example: layout, backend and a CI workflow
infra/
modules/
data-lake/ # main.tf, variables.tf, outputs.tf, versions.tf
envs/
dev/
main.tf # module "lake" { source = "../../modules/data-lake" environment = "dev" }
backend.tf
prod/
main.tf
backend.tfterraform {
backend "s3" {
bucket = "acme-terraform-state-prod"
key = "data-platform/terraform.tfstate"
region = "ap-south-1"
encrypt = true
use_lockfile = true # S3-native locking (Terraform 1.10 and later); older setups used a DynamoDB table
}
}name: Terraform
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
permissions:
contents: read
id-token: write # short-lived cloud credentials via OIDC, no stored keys
jobs:
plan:
runs-on: ubuntu-latest
strategy:
matrix:
env: [dev, prod]
defaults:
run:
working-directory: infra/envs/${{ matrix.env }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TERRAFORM_PLAN_ROLE }}
aws-region: ap-south-1
- run: terraform fmt -check -recursive ../..
- run: terraform init -input=false
- run: terraform validate
- run: terraform plan -input=false -out=tfplan
- run: terraform show -no-color tfplan > plan.txt
- uses: actions/upload-artifact@v4
with:
name: plan-${{ matrix.env }}
path: infra/envs/${{ matrix.env }}/plan.txtApplying uses the same pattern in a separate job that runs only on main, with a GitHub environment requiring approval for prod and a more privileged role that only that job can assume. Planning uses a read-only role.
Common mistakes
Local state. A lost laptop is lost infrastructure knowledge. Use a remote backend from day one.
One giant state file for everything. Plans get slow and every change risks everything. Split by system and environment.
Long-lived cloud keys in CI. Use OIDC federation for short-lived credentials.
Copy-pasting environments. Differences creep in. Share modules, vary inputs.
Interview angle
"How would you structure Terraform for 20 teams and three environments?" Cover modules owned by a platform team, per-team and per-environment state, remote backends with locking, CI plans on pull requests, gated applies, OIDC credentials, policy checks and drift detection.
Practice task
Split the data lake from the basics lesson into a module and two environments.
Add Checkov or tfsec to the plan job and make it fail on a public bucket.
Schedule a nightly
terraform plan -detailed-exitcodethat alerts when it exits with 2 (changes detected).