← Back to Blog RSS

How to build a Terraform CI/CD pipeline that works at scale

Terraform DevOps Infrastructure State Management

Adding plan and apply steps to your Terraform CI/CD pipeline and getting Terraform to run inside a runner at all is just the start. The hard part comes after: what the lock actually blocks, what a reviewer is supposed to do with a wall of plan diff, and what happens to a change that has to span more than one state file.

TL;DR
$ cat terraform-ci-cd.tldr
• See where the standard init, plan, and apply mechanics actually belong in a pipeline, and the environment and secrets structure that keeps them safe.
• Why whole-state locking quietly serializes engineers who aren't even touching the same resources.
• How to gate production changes with approval workflows a reviewer can actually reason about.
• What changes about locking, blast radius, and cross-state changes once you move past the basic pipeline.

Every team that's run Terraform has a version of the same pipeline: a plan on pull request, an apply on merge, a backend that locks the state file, and a Slack channel that pages someone when the apply hangs.

Getting that far isn't the hard part anymore. Every CI system from GitHub Actions to Azure DevOps has enough native support for Terraform that the plumbing barely counts as a decision these days.

The challenge is what happens once that pipeline feels fully set up: when there are a dozen engineers pushing changes to the same production environment in the same week, state files start representing hundreds of resources instead of a dozen, and a plan diff is too long for anyone to read closely enough to trust.

This article walks through the mechanics worth getting right (repo structure, pipeline stages, platform choice, gating, and security) and then spends real time on the part that tutorials leave out: what actually breaks once the basic pipeline is working, and what a graph-aware execution model changes about the answer.

What is needed to integrate Terraform with CI/CD

A Terraform CI/CD pipeline has a few requirements, and skipping any one of them is usually what causes the incident retro.

That's how Terraform integrates with CI/CD pipelines at the level most engineers actually need day to day, and it's also most of what a CI/CD pipeline for Terraform has to be at a mechanical level: a runner that can check out code, authenticate to a cloud account, and execute a couple of CLI commands in the right order with the right guardrails around them.

Observation

Terraform doesn't require a special integration layer so much as discipline around a handful of ordinary CI primitives. The interesting engineering is in the guardrails, not the integration.

Where this gets more particular is how each of those requirements gets implemented, because the naive version of each one has a failure mode that only shows up once a pipeline is actually carrying production traffic.

A deterministic init that isn't pinned to exact provider versions will drift silently between runs. A plan step that re-plans at apply time instead of applying a saved artifact can apply something a reviewer never actually saw.

State locking that works fine for one team locks out everyone else the moment two teams share a production environment. The rest of this article is about those failure modes, one section at a time.

Structuring the repository and environment configurations

The first real decision is how much of the repository maps to environments and how many environments there are. It's worth setting up for multiple environments deliberately rather than inheriting whatever the first engineer on the project happened to create.

Two patterns dominate: separate environment configurations as distinct root modules (an environments/staging and environments/production directory, each a properly isolated environment with its own backend configuration and variables) or a single root module driving multiple workspaces that share both.

Both patterns typically exist in the same repository; splitting environments across separate repositories adds coordination overhead without buying isolation you don't already get from separate state.

Workspaces are the smaller commitment, and for a handful of near-identical environments they work fine. The catch is that workspaces share a backend configuration and a root module by design, which is exactly the property that becomes a liability once staging and production genuinely diverge (different instance sizes, different regional footprints, different provider versions during a careful upgrade).

Separate root modules cost more duplication up front but let each environment's Terraform files drift independently when they need to, without a workspace-scoped conditional creeping into every resource block.

module "vpc" {
  source = "../../modules/vpc"
 
  environment = "production"
  cidr_block = "10.20.0.0/16"
  availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
 
module "app_cluster" {
  source = "../../modules/ecs-service"
 
  environment = "production"
  cluster_name = "app-production"
  desired_count = 6
  vpc_id = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
}

Whichever pattern you pick, keep environment variables and Terraform variables out of the configuration itself. Stable values (like bucket name, region, and key prefix) belong in the backend configuration; credentials and anything secret get injected as environment variables or CI secrets at terraform init time, never committed alongside the .tf files they authenticate against.

The core pipeline stages: init, validate, plan, and apply

terraform init is the stage most pipelines get quietly wrong.

It's easy to let it succeed against whatever provider versions happen to be cached on the runner that week. Provider and module registries are external dependencies the pipeline doesn't control, so pin versions explicitly, commit the lock file, and treat an init that resolves differently on two different runners as a bug, not an occasional annoyance.

A terraform validate run belongs immediately after init and before plan. It's cheap, it catches malformed HCL and type errors before you spend a plan cycle discovering them, and it's a legitimate place to fail fast in a PR check without ever touching a cloud account.

Plan generation is where most of you'll get most of the actual review value, and the detail that separates a solid pipeline from a fragile one is whether the plan step produces a genuine artifact.

terraform plan -out=tfplan followed by uploading tfplan as a build artifact means the apply stage later runs terraform apply tfplan, an exact binary representation of what a reviewer approved, not a fresh plan generated from whatever the state and configuration happen to look like at merge time (which might differ if someone else applied a change in between).

name: terraform-plan
 
on:
  pull_request:
    paths:
      - "environments/**"
 
jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
 
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: "1.9.8"
 
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/terraform-ci
          aws-region: us-east-1
 
      - name: Terraform init
        working-directory: environments/production
        run: terraform init
 
      - name: Terraform validate
        working-directory: environments/production
        run: terraform validate
 
      - name: Terraform plan
        working-directory: environments/production
        run: terraform plan -out=tfplan -input=false
 
      - uses: actions/upload-artifact@v4
        with:
          name: tfplan-production
          path: environments/production/tfplan

That plan artifact is also what makes a real review possible: post the human-readable plan output as a pull request comment (terraform show -no-color tfplan), and let the reviewer approve the same Terraform state transition that the apply job later executes, byte for byte.

Choosing your CI/CD platform

Terraform doesn't have a strong opinion about which CI system runs it, which is mostly good news: GitHub Actions, GitLab CI, Azure DevOps pipelines, and Buildkite can all run the same init, validate, plan, apply sequence with equivalent guarantees around secrets and concurrency.

Most of what actually slows a Terraform pipeline down has little to do with which platform you pick and everything to do with what your organization already relies on.

GitHub Actions has become something close to a default for teams without an existing platform commitment, mostly because OpenID Connect (OIDC) federation to AWS, GCP, and Azure is well documented, and pull_request triggers map naturally onto the plan-on-PR, apply-on-merge pattern. The example above uses exactly that: a workload identity role assumed via OIDC, no long-lived access keys sitting in a secrets store waiting to leak.

Azure DevOps pipelines earn their native support in a different way: teams already committed to the Microsoft ecosystem get first-class integration between Azure Key Vault and pipeline variable groups, so secret management doesn't require a separate tool.

The equivalent plan stage there looks structurally identical, just expressed in YAML with a different secret injection mechanism:

trigger: none
pr:
  branches:
    include:
      - main
 
pool:
  vmImage: ubuntu-latest
 
variables:
  - group: terraform-production-secrets
 
steps:
  - task: TerraformInstaller@1
    inputs:
      terraformVersion: "1.9.8"
 
  - task: TerraformTaskV4@4
    displayName: "Terraform init"
    inputs:
      provider: "azurerm"
      command: "init"
      workingDirectory: "$(System.DefaultWorkingDirectory)/environments/production"
      backendServiceArm: "terraform-production-sc"
      backendAzureRmResourceGroupName: "tfstate-rg"
      backendAzureRmStorageAccountName: "tfstateprod001"
      backendAzureRmContainerName: "tfstate"
      backendAzureRmKey: "production.terraform.tfstate"
 
  - task: TerraformTaskV4@4
    displayName: "Terraform plan"
    inputs:
      provider: "azurerm"
      command: "plan"
      workingDirectory: "$(System.DefaultWorkingDirectory)/environments/production"
      environmentServiceNameAzureRM: "terraform-production-sc"

Whichever platform you pick, a couple of things stay constant:

  1. Custom scripts that wrap terraform commands should be genuinely thin (formatting, tagging, and a shared TF_VAR_ prefix, at most), not a place where pipeline-specific logic quietly reimplements what Terraform already does.
  2. Build environments should run from pinned container images rather than whatever version happens to be installed on the runner, so a plan generated in CI matches what an engineer sees running the same image locally.

Gating production changes

Branch protection rules are the first gate, and they're cheap: require the plan job to pass as a required check before merge, require at least one review, and disallow force-pushes to the branch that triggers apply.

None of that is Terraform-specific, but skipping it on the repository that manages your production infrastructure is a strange place to be lax.

Manual approval belongs at the environment boundary, not the pull request. Most CI systems support environment-scoped approval gates that pause the apply job until a designated approver signs off, which is a meaningfully different control from PR review: PR review approves the intent of a change; the environment gate approves that this specific plan, against this specific environment, is safe to execute right now.

Environment promotion should carry the same plan artifacts through each stage rather than re-planning at every hop.

A change promoted from staging environments to the production environment ought to be the same configuration, ideally the same commit, planned and applied against each of the different environments in sequence, not independently regenerated plans that could each drift from what was actually tested upstream.

Observation

The honest limitation here is that a plan diff, even a well-gated one, is a wall of text. A reviewer approving a 40-resource plan often has to trust that nothing in the noise matters, and that trust is exactly where things go wrong at scale.

Securing the pipeline

Security tools in a Terraform CI/CD pipeline earn their keep by running automatically on every plan, not as a manual step someone remembers before a big release.

A scanner like Checkov catches a meaningful class of misconfiguration before it ships: public S3 buckets, overly permissive security groups, IAM policies with wildcard actions, unencrypted storage, and other potential security vulnerabilities that are easy to miss in a code review. Wire it in as a required check the same way you'd wire in terraform validate, and treat a failure as blocking, not advisory.

Access control depends as much on who can trigger an apply against production infrastructure as on what the infrastructure itself allows.

Scope the CI identity narrowly (e.g., a role that can act on the specific Terraform resources the pipeline manages, not a broad administrator role reused across every environment) and keep production infrastructure applies restricted to a protected branch with required reviewers, never a branch anyone can push to directly.

Policy enforcement is often the final step, but this makes it no less important.

Tools like Open Policy Agent (OPA) let you write policy as code and run it against the plan's JSON output as a pipeline gate, blocking non-compliant resources before merge instead of catching them during an audit.

Cost estimation belongs in the same gate: a tool like Infracost annotates the same plan output with a dollar delta, so a change that would quietly triple a database's monthly bill gets a second look before it ships, not after the invoice arrives.

Where native CI/CD pipelines hit a ceiling

Everything covered above is achievable with a CI system, a well-structured repository, and enough discipline to keep both honest. However, there is usually a point where a team scales past this point and has more requirements.

State locking

Every remote backend across every major cloud provider, including S3 with DynamoDB on AWS, Azure's blob lease, GCS's object locking, implements the same mechanism against the same remote state file: a single lock on the entire thing.

That's fine when one engineer applies at a time, but it becomes a bottleneck the moment two engineers need to change unrelated resources (like one updating a security group and another resizing an autoscaling group) in the same production state, because the lock has no concept of what is unrelated.

One queues behind the other regardless of whether their changes could safely run concurrently.

Blast radius visibility

A plan diff tells you what will change, but not the blast radius of your change or how much of your dependency graph sits downstream of that change if something goes wrong.

A reviewer scanning forty lines of plan output has no native way to see that fifteen of those resources are load-bearing for three other services and twenty-five are inert tags, which means review quality degrades exactly as fast as plan size grows.

Drift going undetected

Drift detection only happens when the pipeline happens to run. If nobody's merged a change in two weeks, and someone made a manual console change in week one, that drift sits undetected until the next terraform plan stumbles across it, by which point the gap between what's declared and what's actually running in the cloud provider has had two weeks to compound.

Spanning more than one state file

And a change that legitimately spans more than one state file, such as a networking change that a compute layer and an application layer both depend on, has no atomic path through a standard pipeline.

You either apply each state independently and hope the ordering and timing work out, or you build custom orchestration scripts to coordinate them, which is its own maintenance burden layered on top of the pipeline you already built.

Graph-aware execution: a different model for the pipeline

None of the blockers above are solvable by adding another CI stage, because the constraint sits underneath the pipeline, in how Terraform represents state as a single flat file rather than a queryable structure.

That's the problem Stategraph is built around: it stores your infrastructure's dependency graph in a database and lets your pipeline operate on the specific subgraph a change actually touches, instead of treating the whole state as one indivisible unit every time.

Getting there is a migration rather than a swap. Your HCL, modules, and providers carry over, but state moves into Stategraph with stategraph import tf first, and the compatibility reference is the command-by-command map of where Terraform's surface area stands today.

Once that's done, Stategraph drives Terraform underneath, so the GitHub Actions plan step from earlier barely changes shape:

      - name: Stategraph plan
        working-directory: environments/production
        run: stategraph tf plan --tenant "$STATEGRAPH_TENANT_ID" --out plan.json
        env:
          STATEGRAPH_API_KEY: ${{ secrets.STATEGRAPH_API_KEY }}
          STATEGRAPH_TENANT_ID: ${{ secrets.STATEGRAPH_TENANT_ID }}

What changes is what happens underneath that command.

Because the dependency graph is explicit rather than inferred fresh on every run, Velocity (Stategraph's execution engine) can plan and apply independent subgraphs concurrently and lock at the level of individual resources rather than the whole file, so the engineer resizing an autoscaling group and the engineer updating an unrelated security group stop queuing behind each other inside the same production state.

A change that spans multiple states gets the same treatment through a single multi-state transaction:

$ stategraph tf mtx --tenant "$STATEGRAPH_TENANT_ID" --out plan.json \
  ./environments/production/networking \
  ./environments/production/compute \
  ./environments/production/application

That command plans all three layers together and applies them as one atomic unit, which is exactly the coordination problem the previous section flagged as unsolved by a standard pipeline.

Insights, Stategraph's observability layer, is what addresses the review problem: blast radius analysis runs against the actual graph, so a reviewer (or an approval gate) can understand and act on how many resources and services a change touches, instead of scrolling through forty lines of undifferentiated plan output.

Because the graph is a live, queryable structure rather than a snapshot regenerated at plan time, drift detection is no longer tied to when the pipeline happens to run: continuous drift detection surfaces a manual console change the day it happens rather than the next time someone opens a pull request.

Design Principle

None of this replaces the pipeline built in the sections above; it sits underneath it.

You still want branch protection, plan-on-PR, policy gates, and a scoped CI identity; graph-aware execution changes what the plan and apply steps are capable of once they run, not whether you need them.

Conclusion

The pipeline mechanics that get most of the attention (a plan job, an apply job, a lock on the backend) are also the easiest part to get right. The part worth spending real engineering time on is what happens once that pipeline is carrying genuine production traffic: whether the lock still makes sense when ten engineers share a state file, whether a reviewer can actually reason about a plan instead of skimming it, and whether a change that spans more than one state file has an atomic path or a hopeful one.

Graph-aware execution doesn't replace any of the discipline covered above; it's what makes resource-level locking, real blast radius review, and cross-state transactions possible.

If your pipeline has outgrown the basic version, try Stategraph free and see what your existing plan and apply steps look like running against a graph instead of a flat file.