Search…

Terraform in CI/CD

In this series (10 parts)
  1. Introduction to Infrastructure as Code
  2. Terraform fundamentals
  3. Terraform state management
  4. Terraform modules
  5. Terraform in CI/CD
  6. Ansible fundamentals
  7. Ansible roles and best practices
  8. Packer for machine images
  9. CloudFormation and CDK
  10. Managing drift and compliance

Running terraform apply from a laptop works for prototypes. It stops working the moment a second engineer touches the same state file. Changes happen without review. Drift accumulates. Nobody knows who changed what or when. The fix is straightforward: treat infrastructure the same way you treat application code. Put it in version control, run it through a CI/CD pipeline, and require peer review before anything reaches production.

This article walks through the mechanics of making that happen. You will see how to wire Terraform into GitHub Actions, how to enforce policies before apply, and how to catch cost surprises before they land on your bill.

The plan/apply split

Terraform separates reads from writes. terraform plan computes a diff between your desired state and the real world. terraform apply executes that diff. This separation is the foundation of every safe automation workflow.

In a CI/CD context the split maps naturally onto pull request events. When a developer pushes a branch, the pipeline runs plan and posts the output as a PR comment. Reviewers read the diff. If it looks correct, merging the PR triggers apply. No human ever runs apply by hand.

This approach gives you three things at once: an audit trail in git history, a review gate on every change, and a consistent execution environment that eliminates works-on-my-machine problems.

GitOps pipeline for Terraform

A GitOps model means git is the single source of truth. The pipeline below shows the full lifecycle from commit to deployed infrastructure.

graph TD;
  A[Developer pushes branch] --> B[PR opened];
  B --> C[terraform fmt check];
  C --> D[terraform validate];
  D --> E[terraform plan];
  E --> F[Post plan output to PR];
  F --> G[Infracost estimate];
  G --> H[OPA policy check];
  H --> I{Reviewers approve?};
  I -->|Yes| J[Merge to main];
  I -->|No| K[Developer revises];
  K --> B;
  J --> L[terraform apply];
  L --> M[State updated in remote backend]

Terraform GitOps pipeline from PR to deployed infrastructure.

Every box in that diagram is automated except the review step. That single human gate is intentional. Fully automated apply without review is possible for low-risk environments, but most teams keep the approval step for production.

GitHub Actions workflow

Below is a complete workflow file. It runs plan on pull requests and apply on merges to main. State lives in an S3 backend with DynamoDB locking.

name: Terraform

on:
  pull_request:
    branches: [main]
    paths: [infra/**]
  push:
    branches: [main]
    paths: [infra/**]

permissions:
  contents: read
  pull-requests: write
  id-token: write

env:
  TF_VERSION: "1.7.5"
  WORKING_DIR: "infra"
  AWS_REGION: "us-east-1"

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform fmt
        run: terraform fmt -check -recursive
        working-directory: ${{ env.WORKING_DIR }}

      - name: Terraform init
        run: terraform init -input=false
        working-directory: ${{ env.WORKING_DIR }}

      - name: Terraform validate
        run: terraform validate
        working-directory: ${{ env.WORKING_DIR }}

      - name: Terraform plan
        id: plan
        run: terraform plan -input=false -no-color -out=tfplan
        working-directory: ${{ env.WORKING_DIR }}

      - name: Post plan to PR
        uses: actions/github-script@v7
        with:
          script: |
            const output = `${{ steps.plan.outputs.stdout }}`;
            const truncated = output.length > 60000
              ? output.substring(0, 60000) + '\n\n... truncated ...'
              : output;
            const body = `### Terraform Plan\n\`\`\`\n${truncated}\n\`\`\``;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: body
            });

  apply:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform init
        run: terraform init -input=false
        working-directory: ${{ env.WORKING_DIR }}

      - name: Terraform apply
        run: terraform apply -input=false -auto-approve
        working-directory: ${{ env.WORKING_DIR }}

A few details worth noting. The id-token: write permission enables OIDC federation with AWS, avoiding long-lived credentials. The environment: production block on the apply job lets you configure required reviewers as an additional gate.

Atlantis vs GitHub Actions

Atlantis is a dedicated Terraform PR automation server. You self-host it and it watches for webhooks. Someone comments atlantis plan, it runs plan and posts the result. atlantis apply triggers apply after approval. GitHub Actions handles the same workflow using the native CI system.

ConcernAtlantisGitHub Actions
HostingSelf-hosted serverManaged by GitHub
LockingBuilt-in workspace lockingManual via DynamoDB or similar
Multi-repoSingle server handles many reposWorkflow per repo or reusable workflows
CredentialsStored on the Atlantis serverOIDC or GitHub secrets
Custom logicServer-side repo config and scriptsComposite actions and reusable workflows

Atlantis shines when you manage dozens of Terraform repos and want consistent behavior without duplicating workflow files. GitHub Actions wins when you want everything in one platform with no extra infrastructure.

Cost estimation with Infracost

Terraform tells you what will change. It does not tell you what it will cost. Infracost fills that gap. It parses your plan output and estimates monthly cost impact using cloud provider pricing APIs.

Add this step after the plan step in your workflow:

      - name: Setup Infracost
        uses: infracost/actions/setup@v3
        with:
          api-key: ${{ secrets.INFRACOST_API_KEY }}

      - name: Run Infracost
        run: |
          infracost breakdown --path=${{ env.WORKING_DIR }} \
            --format=json --out-file=infracost.json
          infracost comment github \
            --path=infracost.json \
            --repo=${{ github.repository }} \
            --pull-request=${{ github.event.pull_request.number }} \
            --github-token=${{ secrets.GITHUB_TOKEN }} \
            --behavior=update

The comment shows a table with current cost, new cost, and the delta. Reviewers see the financial impact directly on the PR. A change that adds three m5.2xlarge instances is easier to evaluate when the comment says ”+$1,248/mo” right next to the plan diff.

You can also set threshold alerts. If a PR increases monthly cost by more than a defined percentage, the pipeline fails and forces a conversation before merge.

Policy as code with OPA and Sentinel

Cost is one constraint. Security and compliance are others. Policy as code lets you codify rules and enforce them automatically.

Open Policy Agent (OPA) uses the Rego language. You write policies as data queries against the Terraform plan JSON.

package terraform

deny[msg] {
    resource := input.planned_values.root_module.resources[_]
    resource.type == "aws_s3_bucket"
    not resource.values.server_side_encryption_configuration
    msg := sprintf("S3 bucket %s must have encryption enabled", [resource.address])
}

deny[msg] {
    resource := input.planned_values.root_module.resources[_]
    resource.type == "aws_security_group_rule"
    resource.values.cidr_blocks[_] == "0.0.0.0/0"
    resource.values.type == "ingress"
    msg := sprintf("Security group rule %s must not allow 0.0.0.0/0 ingress", [resource.address])
}

Run this in CI by converting the plan to JSON and evaluating it:

terraform show -json tfplan > plan.json
opa eval --data policy/ --input plan.json "data.terraform.deny" --fail-defined

If any deny rule matches, the pipeline fails. The error message tells the developer exactly what to fix.

HashiCorp Sentinel is the commercial alternative built into Terraform Cloud and Terraform Enterprise. It uses its own language and integrates directly with the plan/apply lifecycle. Sentinel policies run between plan and apply without extra tooling. Sentinel offers advisory (warn but allow) and hard-mandatory enforcement levels.

OPA is free, open source, and works with any CI system. Choose it when you run open source Terraform with your own pipelines. Choose Sentinel if your organization uses Terraform Cloud.

Putting it all together

A mature Terraform CI/CD pipeline chains these stages in order: format check, validate, plan, cost estimate, policy evaluation, human review, apply. Each stage is a gate. If any gate fails, the pipeline stops and the developer gets clear feedback.

The key principles are simple. Never apply from a local machine. Always plan before apply. Always review before merge. Automate every check that does not require human judgment. Keep state remote and locked.

What comes next

This article covered the automation layer around Terraform. The pipeline is only as good as the modules it runs. Invest in small, tested, composable modules. Build a private module registry so teams reuse proven patterns. Add automated tests using Terratest or terraform test. Monitor state drift by running plan on a schedule and alerting when the output is nonempty.

Infrastructure as code matures the same way application code does: through iteration, review, and automation.

Start typing to search across all content
navigate Enter open Esc close