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 planagainst 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 applyusing 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
-outon plan and feed that exact file toapply. Without it,applyruns 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.
| Concern | Anti-pattern | Recommended |
|---|---|---|
| State storage | Local terraform.tfstate in git | Remote backend (S3, GCS, TFC) with locking |
| Cloud auth | Static AWS_ACCESS_KEY_ID secret | OIDC federation, short-lived role assumption |
| App secrets | Hardcoded in .tf files | Secret manager + data source, injected at runtime |
| Plan artifact | Re-plan at apply time | Saved -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
applyfor 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-exitcodeto 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.
| Tool | Model | Hosting | Best for |
|---|---|---|---|
| GitHub Actions | YAML workflows | SaaS / self-hosted runners | Teams already on GitHub; full control |
| GitLab CI | .gitlab-ci.yml + managed Terraform state | SaaS / self-hosted | GitLab shops wanting built-in state |
| Atlantis | PR comment automation (atlantis plan/apply) | Self-hosted server | Pure plan/apply-via-PR workflow, any VCS |
| Terraform Cloud / HCP | Managed runs + state + policy | SaaS | Hands-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
-outand 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
applybehind a manual approval, even when plan is automatic. - Enforce policy as code so the pipeline rejects unsafe plans before they merge.
- Pin
required_versionand provider versions so CI and local runs match exactly. - Run scheduled drift detection with
-detailed-exitcodeto catch console changes.