Infrastructure as code · Advanced

Terraform in a team: state, modules, environments and CI

3 min read

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 and plan, 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

layout
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.tf
envs/prod/backend.tf
terraform {
  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
  }
}
.github/workflows/terraform.yml
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.txt

Applying 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

  1. Split the data lake from the basics lesson into a module and two environments.

  2. Add Checkov or tfsec to the plan job and make it fail on a public bucket.

  3. Schedule a nightly terraform plan -detailed-exitcode that alerts when it exits with 2 (changes detected).

Further reading