Skip to content
Infrastructure as Code iac cicd 5 min read

CI/CD for Terraform

Running Terraform from a laptop works for a weekend project, but it falls apart the moment a team shares infrastructure: state drifts, secrets leak into shell history, and nobody can tell what a change will do before it lands. Putting Terraform behind a CI/CD pipeline fixes this by making every change a reviewable, repeatable, auditable event. The core idea is simple: generate a plan automatically when a pull request opens, and run apply automatically once that PR is approved and merged. This page sets up the section and frames the decisions you’ll make before wiring up a specific tool.

The plan-on-PR, apply-on-merge model

Almost every healthy Terraform pipeline follows the same two-phase shape, and it maps cleanly onto the way teams already review code.

  • Plan on PR. When a developer opens a pull request, the pipeline runs terraform plan against the real state and posts the diff back as a comment or check. Reviewers see exactly which resources will be created, changed, or destroyed before approving — no surprises.
  • Apply on merge. Once the PR is approved and merged to the default branch, the pipeline runs terraform apply using the plan that was already reviewed. The merge is the deployment trigger.

The pattern works because Terraform can save a plan to a file and replay it, guaranteeing that what you reviewed is what gets applied.

# Phase 1 — runs on every pull request
terraform init -input=false
terraform plan -input=false -out=tfplan

# Phase 2 — runs after merge to main, replaying the saved plan
terraform apply -input=false tfplan

Output:

Terraform will perform the following actions:

  # aws_s3_bucket.assets will be created
  + resource "aws_s3_bucket" "assets" {
      + bucket = "devcraftly-assets-prod"
      + id     = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Saved the plan to: tfplan

Tip: Always pass -out on plan and feed that exact file to apply. Without it, apply runs a fresh plan at merge time, which may differ from what reviewers approved if the cloud state changed in between.

This flow is provider-agnostic and works identically with OpenTofu — swap the terraform binary for tofu and the commands are the same.

Where state and secrets live

A pipeline runs on ephemeral, untrusted runners, so two things must live somewhere durable and secure: remote state and credentials.

State must be in a shared remote backend with locking, so concurrent runs can’t corrupt it. A typical AWS setup uses S3 with native state locking (Terraform 1.10+ supports S3 lockfiles directly, removing the old DynamoDB requirement).

terraform {
  required_version = ">= 1.10"

  backend "s3" {
    bucket       = "devcraftly-tfstate"
    key          = "prod/network/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true
  }
}

Secrets — cloud credentials, API tokens — must never sit in the repo or in plaintext pipeline variables. The modern approach is short-lived OIDC tokens: the CI provider authenticates to your cloud via federation and receives temporary credentials scoped to the job, so there are no long-lived keys to rotate or leak.

ConcernAnti-patternRecommended
State storageLocal terraform.tfstate in gitRemote backend (S3, GCS, TFC) with locking
Cloud authStatic AWS_ACCESS_KEY_ID secretOIDC federation, short-lived role assumption
App secretsHardcoded in .tf filesSecret manager + data source, injected at runtime
Plan artifactRe-plan at apply timeSaved -out plan replayed on merge

Approvals and guardrails

Automation should make safe changes effortless and risky changes deliberate. Layer these guardrails so the pipeline blocks bad merges rather than relying on human vigilance.

  • Required reviews on the PR before merge is allowed.
  • Manual approval gate between merge and apply for production environments.
  • Policy as code — Sentinel (TFC) or Open Policy Agent / Conftest — to reject plans that violate rules, e.g. “no public S3 buckets.”
  • -detailed-exitcode to detect drift programmatically.
terraform plan -input=false -detailed-exitcode -out=tfplan
# exit 0 = no changes, 1 = error, 2 = changes present

A scheduled drift-detection job that runs the plan on a cron and alerts when exit code 2 appears (with no open PR) catches out-of-band console changes early.

Tooling options

There’s no single “best” tool — the right choice depends on where your code lives and how much you want to operate yourself.

ToolModelHostingBest for
GitHub ActionsYAML workflowsSaaS / self-hosted runnersTeams already on GitHub; full control
GitLab CI.gitlab-ci.yml + managed Terraform stateSaaS / self-hostedGitLab shops wanting built-in state
AtlantisPR comment automation (atlantis plan/apply)Self-hosted serverPure plan/apply-via-PR workflow, any VCS
Terraform Cloud / HCPManaged runs + state + policySaaSHands-off state, Sentinel policy, RBAC

GitHub Actions and GitLab CI give you raw pipeline primitives — you assemble the plan/apply logic yourself. Atlantis is purpose-built for Terraform: it listens on PRs and turns comments into runs. Terraform Cloud (now part of HCP) bundles state, runs, and governance into a managed service, and is compatible with OpenTofu via the tofu execution mode in self-hosted agents.

Best practices

  • Save the plan with -out and apply that exact artifact — never re-plan at apply time.
  • Authenticate with OIDC and short-lived credentials; eliminate static cloud keys.
  • Keep state in a locking remote backend, isolated per environment.
  • Gate production apply behind a manual approval, even when plan is automatic.
  • Enforce policy as code so the pipeline rejects unsafe plans before they merge.
  • Pin required_version and provider versions so CI and local runs match exactly.
  • Run scheduled drift detection with -detailed-exitcode to catch console changes.
Last updated June 14, 2026
Was this helpful?