Skip to main content

Terraform in CI/CD: running IaC in a pipeline

You can now write Terraform, keep its state safe, and reuse it with modules. One question remains, and it's the one that separates a hobby project from a team: who actually runs terraform apply, and from where? If the answer is "an engineer, from their laptop," you've recreated every problem IaC was supposed to kill — unreviewed changes, no audit trail, and state that depends on one person's environment. The fix is to run Terraform inside a pipeline, the same way application code ships. This lesson shows how, and how the IaC delivery loop differs from the app GitOps you'll meet in Chapter 5.

The core loop: plan on PR, apply on merge

The whole discipline reduces to two automated steps tied to your Git workflow:

  • Plan on pull request. When someone opens a PR that changes .tf files, the pipeline runs terraform plan and posts the result as a comment on the PR. Now every reviewer sees exactly what the change will create, modify, or destroy — before anyone approves. The plan becomes part of code review, which is the single most valuable thing automation adds: a destructive 1 to destroy shows up in the diff, not in a 2 a.m. incident.
  • Apply on merge. Once the PR is approved and merged to the main branch, the pipeline runs terraform apply against the plan. The merge — a reviewed, recorded Git event — is the only way infrastructure changes. No one applies from a laptop.
Open PR\n(.tfchange)CI: terraformplan\n→ comment onPRHuman review\n(readsthe plan)Merge to mainCD: terraformapply\n(reviewedplan)Real infrastructure

This is the durable pattern regardless of tool: the plan is a review artifact; the merge is the trigger; the laptop is never the source of an apply. Everything below is machinery to make those two steps safe.

The platforms: raw CI, Atlantis, and Terraform Cloud/HCP

Three common ways to run that loop, from most assembly-required to most managed:

  • Raw CI runner (GitHub Actions, GitLab CI, etc.). You write the workflow yourself: a job that runs terraform plan on PRs and terraform apply on merge. Maximum control, but you own the hard parts — state locking against concurrent pipeline runs, secret injection, and posting the plan to the PR. Fine for small setups; fiddly as teams grow.
  • Atlantis. An open-source tool dedicated to exactly this loop. It listens for PR webhooks, runs plan automatically, comments the result, and lets you trigger apply by typing atlantis apply in a PR comment after approval. It centralizes runs (so two PRs can't apply at once) and keeps the workflow in the PR where review already happens. You self-host it.
  • Terraform Cloud / HCP Terraform (and Spacelift, Env0, etc.). A managed service that runs plans and applies for you, stores state, enforces locking, manages secret variables, and adds policy-as-code gates. You give up self-hosting and some cost in exchange for not building the plumbing. This is where most serious teams land.

:::tip Don't hand-roll what a platform gives you A raw-CI Terraform pipeline looks simple until you hit the third concurrent PR, the leaked secret, or the "who applied this?" question. Atlantis or a managed runner exists precisely to solve PR-comment plans, run serialization, and secret handling for you. Reach for raw CI only when the setup is genuinely small. :::

Handling secrets in IaC pipelines

Terraform needs credentials — to authenticate to the cloud, and sometimes provider inputs like a database password. A pipeline that does this badly is a breach waiting to happen. The rules:

  • Never put long-lived cloud keys in the pipeline. Use OIDC federation so the CI runner exchanges a short-lived, signed identity token for temporary cloud credentials at run time — the exact keyless pattern from workload identity. No static AWS_SECRET_ACCESS_KEY stored in the CI system to leak.
  • Inject sensitive variables from a secrets manager, not from plaintext in the repo or workflow file. The pipeline pulls them at run time; they never live in Git.
  • Remember state holds secrets too. As you learned in state & drift, the state file can contain sensitive attributes. Pipeline-managed remote state must be encrypted and access-controlled — the pipeline's service identity, and few others, should be able to read it.

The throughline: the pipeline authenticates as a short-lived machine identity, reads secrets from a manager at run time, and writes encrypted state. Nothing sensitive is ever at rest in the repo.

Testing IaC: validate, plan checks, and policy-as-code

App code has unit tests; infrastructure code needs its own gates, layered cheapest-first in the pipeline:

  1. Format & validate. terraform fmt -check (consistent style) and terraform validate (syntactically and internally consistent config) — instant, catch typos before anything else runs.
  2. Plan as a check. The plan isn't just for humans; the pipeline can fail the build on it — e.g. block any plan that destroys a resource tagged protected, or require a plan to be clean before merge. Reading the plan is a test.
  3. Policy-as-code. The big one. Tools like OPA (Open Policy Agent, with its Rego language) and Sentinel (HashiCorp's, built into Terraform Cloud/HCP) evaluate the plan against organizational rules before apply and block violations automatically. Examples: "no security group may open port 22 to 0.0.0.0/0," "every resource must carry a cost-center tag," "no S3 bucket may be public," "only approved instance types." This is automated governance — guardrails that don't depend on a reviewer noticing.
  4. Module/integration tests. For reusable modules, frameworks like Terratest or Terraform's native test apply real (often ephemeral) infrastructure and assert it came out right, then tear it down.

:::note Policy-as-code is the guardrail GitOps and platform teams rely on A human reviewer might miss a public bucket in a 400-line plan; an OPA/Sentinel policy never does. Policy-as-code turns "we have a rule about that" into an automatic, un-bypassable gate in the pipeline — the same posture-and-policy idea you'll see in Cloud Security, applied at apply-time instead of after the fact. :::

A worked pipeline

Trace a change to a production VPC through a Terraform Cloud–style pipeline:

  1. An engineer opens a PR adding a new subnet to network.tf.
  2. CI fires: terraform fmt -check and terraform validate pass, then terraform plan runs. The plan — 2 to add, 0 to change, 0 to destroy — is posted as a PR comment.
  3. Policy check: a Sentinel policy verifies the new subnet carries the required cost-center tag and doesn't overlap an existing CIDR. It passes; a destructive or non-compliant plan would have failed the check and blocked merge.
  4. A teammate reviews the code and the posted plan, sees nothing surprising, and approves.
  5. Merge to main triggers terraform apply against that exact plan, using OIDC to get short-lived cloud credentials and reading the DB password from the secrets manager. State is locked for the duration, updated, and re-encrypted.
  6. The run is recorded — who, what, when, which plan — giving a full audit trail. No laptop touched production.

Every safeguard from the chapter shows up: the plan is reviewed, state is locked and encrypted, secrets are short-lived, and policy is enforced automatically.

The IaC delivery loop vs app GitOps

It's tempting to assume infrastructure ships exactly like applications. It mostly does — Git is the source of truth, changes are reviewed, automation applies them — but there's a real difference worth naming, because Chapter 5 leans on it.

  • App GitOps is pull-based and continuously reconciling. An in-cluster agent (Argo CD, Flux) watches Git and constantly pulls the cluster back to the declared state. It's a control loop that runs forever.
  • Terraform CI/CD is push-based and event-triggered. A pipeline runs apply when a merge happens, then stops. Between merges, nothing is actively reconciling — which is exactly why drift (from the state & drift lesson) is a live concern for infrastructure in a way it isn't for GitOps-managed apps: a console tweak can sit undetected until the next plan. Some teams close this gap by running plan on a schedule purely to detect drift and alert.

Same principles (declarative, reviewed, automated, Git as truth); different engines (pull-reconcile vs push-on-merge). Knowing which you're running tells you where drift can hide.

Pitfalls

  • Applying from a laptop "just this once." It breaks the audit trail and the review gate, and it races the pipeline for the state lock. If the pipeline can apply, only the pipeline applies.
  • Long-lived cloud keys in CI secrets. A static admin key stored in the CI system is a top-tier breach target. Use OIDC for short-lived, keyless auth — there's nothing at rest to steal.
  • Auto-applying without reading the plan. Wiring merge → apply with no human ever reading the posted plan means a destructive change ships silently. The PR-comment plan exists to be read; gate destructive plans behind explicit approval.
  • Two pipelines, one state, no locking. Concurrent applies corrupt state. The platform must serialize runs (lock the state) — don't run Terraform against shared state from two places at once.
  • Treating IaC like GitOps and assuming it self-heals. Nothing reconciles infrastructure between merges; drift accumulates silently. Run scheduled plans to detect it.

Why it matters

Running Terraform in a pipeline is what makes IaC a team practice instead of a personal one. The durable loop is plan-on-PR (the plan becomes a review artifact, so destructive changes surface in the diff) and apply-on-merge (a reviewed, recorded Git event is the only trigger — never a laptop). You run it on raw CI, Atlantis, or a managed runner like Terraform Cloud/HCP; you authenticate with short-lived OIDC credentials and pull secrets from a manager rather than storing keys; and you gate every change with layered tests — fmt/validate, plan-as-a-check, and policy-as-code (OPA/Sentinel) that blocks non-compliant plans automatically. Finally, IaC's push-on-merge loop differs from app GitOps's pull-reconcile loop — which is exactly why drift is a live concern for infrastructure. With this, you can ship infrastructure changes safely and auditably; the checkpoint locks the whole chapter in.

Where this leads: the plan-as-review, OIDC keyless auth, and pull-vs-push distinction connect directly to CI/CD & GitOps (Chapter 5); policy-as-code reappears as posture & policy in Chapter 8. The drift this loop can't auto-correct is the state & drift problem from earlier in this chapter.

Next: Chapter 3 checkpoint →