Learn Terraform - CI/CD Pipeline Automation (GitHub Actions / GitLab CI)
Episode 13 of 21

Learn Terraform - CI/CD Pipeline Automation (GitHub Actions / GitLab CI)

Building a secure CI/CD pipeline for Terraform: the no-laptop-apply principle, a PR flow running fmt, tflint, security scans, and plan, up to an approval gate before apply with cloud authentication via OIDC in GitHub Actions and GitLab CI.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

After discussing in episode 12 how Terragrunt tidies up multi-environment architecture down to a single run-all apply command — in this episode we'll discuss the no less important part: how that execution should run safely and automatically through a CI/CD pipeline.

In episode 11, you already got the toolbox of quality: terraform fmt, terraform validate, tflint, Checkov, and Terratest. All those tools are only useful if run consistently on every code change. If they're run manually from each engineer's laptop, the results depend on the mood, machine, and credentials of the code author — that's a recipe for incidents.

Why is this topic crucial in the real working world? Because one of the most bitter lessons for infrastructure teams is: terraform apply done from a developer's laptop against a production environment is the root of almost all IaC incidents. Admin credentials stored on laptops, no complete audit trail, no effective code review, and one typo can delete production. In this episode, you'll learn to build a pipeline that makes production untouchable from anyone's laptop — only from a controlled, audited, and locked pipeline.

Main Discussion

The Security Principle: Applying from a Laptop Is Strictly Forbidden

Let's start with the most fundamental rule in IaC CI/CD, often called the golden rule of Terraform security:

Important

The golden rule: Production environment credentials must not exist on developer laptops. All changes to production must go through a controlled CI/CD pipeline — with code review, automatic plan, and a human approval gate before apply. Developers only work in non-production environments, or even just wait for plan results from the pipeline.

Why is this rule so strict? Because there are three problems that automatically disappear when production credentials aren't on laptops:

ProblemWith Laptop CredentialsWith Pipeline + OIDC
Audit trailNo trace of who applied and whenEvery run is recorded: commit, user, timestamp
Laptop theft/attackProduction credentials leak along with itNo permanent credentials to steal
Human errorOne typo = production downPlan + review + approval before execution
Least privilege principleDeveloper holds full accessSpecific IAM role per environment & per job

From the table above, the key is OIDC (OpenID Connect) — a passwordless authentication mechanism where CI/CD (e.g. GitHub Actions) gets a short-lived valid token to assume an IAM role in the cloud, without storing any static Access Key anywhere. We'll discuss the implementation shortly.

The Correct Terraform CI/CD Pipeline Flow

Before writing YAML, you must understand the flow pattern first. The Terraform pipeline in professional teams is always split into two big tracks:

StageTriggerToolPurpose
1. FormattingPull Requestterraform fmt -checkEnsuring uniform code style
2. ValidationPull Requestterraform validateEnsuring valid syntax & references
3. LintingPull RequesttflintDetecting provider-specific errors
4. Security ScanPull Requestcheckov / trivyDetecting security misconfigurations
5. PlanPull Requestterraform planPreviewing changes + posting to the PR
6. ApplyMerge to mainterraform applyReal execution, with an approval gate

The pattern above answers an important question: why is plan done in the PR, not after merge? Because the plan posted to the PR comment gives reviewers the ability to assess the impact of changes before approving them. Imagine a reviewer seeing a plan that will add 3 security groups and delete 1 database endpoint — all of that can be corrected before it becomes dangerous.

On the other side, apply only runs after merging to main, and in GitHub Actions that's locked through environment protection rules (the production environment) which require manual approval from an authorized person. That's the approval gate in question.

GitHub Actions: Plan Workflow (Pull Request)

Let's start the implementation. The first workflow runs on every Pull Request. It runs fmt, validate, tflint, checkov, and plan — then posts the plan result to the PR comment:

.github/workflows/plan.yml
name: Terraform Plan
 
on:
  pull_request:
    paths:
      - "infrastructure/**"
    branches:
      - main
 
permissions:
  id-token: write
  contents: read
  pull-requests: write
 
jobs:
  plan:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infrastructure/dev
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.5
 
      - name: Configure AWS Credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gh-oidc-terraform-dev
          aws-region: ap-southeast-1
 
      - name: Terraform fmt
        run: terraform fmt -check -recursive
 
      - name: Terraform validate
        run: terraform validate
 
      - name: Setup tflint
        uses: terraform-linters/setup-tflint@v4
 
      - name: Run tflint
        run: tflint --chdir=infrastructure/dev
 
      - name: Checkov security scan
        uses: bridgecrewio/checkov-action@master
        with:
          directory: infrastructure/dev
          skip_check: CKV_AWS_169
 
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -input=false
 
      - name: Comment plan on PR
        uses: thollander/actions-comment-pull-request@v2
        with:
          message: |
            ## Terraform Plan Result
 
            ```diff
            ${{ steps.plan.outputs.stdout }}
plaintext
 
Let's break down the most important parts:
 
- **`permissions: id-token: write`** — allows the job to request an OIDC token. This is the foundation of passwordless authentication. Without this line, `configure-aws-credentials` will fail.
- **`pull-requests: write`** — needed so the job can write a plan comment to the PR.
- **`role-to-assume`** — the ARN of the IAM role this job assumes. A role specific to the `dev` environment, so plan only has limited access, not full production access.
- **`terraform plan -no-color`** — output without color codes so it's clean when written into a PR comment.
- **`id: plan`** — labels the step. `setup-terraform` wraps the CLI so its `stdout` can be read via `steps.plan.outputs.stdout` and inserted into the comment.
 
<Callout type="tip" descriptionColor="normal">
    One common trap: `terraform plan` output format uses ANSI color notation and unicode characters that can break the PR comment display. Always use `-no-color`, and if the output is too long (hundreds of lines), cut it at a certain limit inside the comment script — many teams add *truncate* logic so PR comments don't become gigantic.
</Callout>
 
### OIDC: Cloud Login Without Static Credentials
 
The part that makes this pipeline secure is **OIDC**. The flow is roughly like this:
 
```bash icon="iBash" title="OIDC authentication flow (conceptual)"
Developer push commit


GitHub Actions requests an ID token from the GitHub OIDC provider
        │  (the token contains claims: repo, ref, environment, job)

AWS STS: assume role with WebIdentity (role gh-oidc-terraform-dev)
        │  (AWS validates the token + policy claims)

Job receives temporary credentials (valid ~1 hour, no stored secret)

On the cloud side, the IAM team creates a role that can be assumed only by OIDC tokens from specific repos & environments. Example trust policy of the gh-oidc-terraform-dev role:

IAM Trust Policy (AWS)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:myorg/infrastructure-live:*"
        }
      }
    }
  ]
}

What's interesting: sub can be narrowed down to a branch, environment, even a specific tag. For example repo:myorg/infra:ref:refs/heads/main — meaning the production role can only be assumed when the pipeline runs on the main branch, and never from anyone's laptop. This makes "applying from a laptop" technically impossible, not just a rule.

Warning

Never store static AWS Access Keys in GitHub Secrets (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY). Even though that old method still works, it locks you into credentials that can leak and can't be rotated automatically. OIDC is the current industry standard: credentials are ephemeral, scoped per job, and never written anywhere. For other cloud providers, the concept is the same: google-github-actions/auth for GCP, azure/login for Azure.

GitHub Actions: Apply Workflow (Merge to Main)

The second workflow runs when code is merged to main. This is the approval gate: the production environment is configured with required reviewers in the repository settings, so terraform apply never runs without approval from an authorized human:

.github/workflows/apply.yml
name: Terraform Apply
 
on:
  push:
    branches:
      - main
    paths:
      - "infrastructure/**"
 
permissions:
  id-token: write
  contents: read
 
jobs:
  apply:
    runs-on: ubuntu-latest
    environment: production
    concurrency: terraform-production
    defaults:
      run:
        working-directory: infrastructure/prod
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.5
 
      - name: Configure AWS Credentials (OIDC) for Production
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gh-oidc-terraform-prod
          aws-region: ap-southeast-1
 
      - name: Terraform Init
        run: terraform init -input=false
 
      - name: Terraform Apply
        run: terraform apply -auto-approve -input=false

Three things to note:

  • environment: production — when a job points to an environment with protection rules (required reviewers), GitHub will hold the job until a reviewer approves. This is the manual approval gate that prevents apply from running automatically without a human.
  • concurrency: terraform-production — prevents two production applies from running at the same time. Remember the state locking lesson from episode 5: without this, two parallel pipelines can overwrite each other's state.
  • -auto-approve — automatically approves the terraform apply confirmation prompt, because human approval has already been captured through environment protection, not at the terminal.

Note

Another common pattern: separating the plan and apply steps in one merge workflow, where plan runs without approval then its result is used by apply with approval. Both are valid; what matters most is that every production apply always passes through human approval and correct state locking.

GitLab CI: Equivalent Workflow

In teams using GitLab, the concept is identical — only the syntax differs. The GitLab pipeline uses stages (like jobs in GitHub) and environments (for approval). Example .gitlab-ci.yml:

.gitlab-ci.yml
stages:
  - validate
  - plan
  - apply
 
variables:
  TF_ROOT: infrastructure/dev
  AWS_REGION: ap-southeast-1
 
.tf_base: &tf_base
  image:
    name: hashicorp/terraform:1.9.5
    entrypoint: [""]
  before_script:
    - terraform --version
 
fmt-validate:
  <<: *tf_base
  stage: validate
  script:
    - cd $TF_ROOT
    - terraform fmt -check -recursive
    - terraform validate
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
 
plan:
  <<: *tf_base
  stage: plan
  script:
    - cd $TF_ROOT
    - terraform init
    - terraform plan -no-color -out=plan.tfplan
  artifacts:
    paths:
      - $TF_ROOT/plan.tfplan
    expire_in: 7 days
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
 
apply:
  <<: *tf_base
  stage: apply
  environment:
    name: production
  script:
    - cd $TF_ROOT
    - terraform apply plan.tfplan
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual

The main differences from GitHub Actions:

  • when: manual on the apply job is GitLab's form of approval gate — the job doesn't run until an engineer presses the "play" button on the pipeline.
  • environment: name: production lets GitLab record deployment history and show environment status in the UI.
  • Artifacts (plan.tfplan) ensure the plan file created in the plan stage is used exactly the same in the apply stage — there's no difference between what was planned and what's applied.

Important

Use a plan file (terraform plan -out=plan.tfplan) instead of re-applying from scratch. With plan.tfplan, the changes reviewed in the plan stage are guaranteed to be exactly the same as those executed in the apply stage — eliminating the risk of "the plan says A, apply turns out B" which can happen if there's drift between the two.

Comparison: GitHub Actions vs GitLab CI

For equivalent configuration, a comparison of the two can be seen here:

# Plan on PR
on:
  pull_request:
    paths: ["infrastructure/**"]
 
# Apply on merge to main
on:
  push:
    branches: [main]
    paths: ["infrastructure/**"]
 
# Approval gate via environment protection
jobs:
  apply:
    runs-on: ubuntu-latest
    environment: production

A concise comparison table:

AspectGitHub ActionsGitLab CI
PR triggeron: pull_requestrules + $CI_PIPELINE_SOURCE
Approval gateEnvironment protection (required reviewers)when: manual
State locking between runsconcurrencyEnvironment deployment_tier / lock
Plan → Apply consistencyStore the plan file as an artifactArtifacts + terraform plan -out
OIDC to AWSconfigure-aws-credentialsid_tokens + aws cli / oidc config

Common IaC CI/CD Pitfalls

Finally, let's talk about the traps that most often make IaC pipelines problematic:

MistakeSymptomSolution
Production credentials on laptops / static secretsLeaks when the laptop is stolen, hard to rotateOIDC + role per environment
Forgetting id-token: writeconfigure-aws-credentials job errors 401Add OIDC permission to the job
Apply without an approval gateChanges can happen without reviewEnvironment protection / when: manual
No concurrencyTwo parallel applies overwrite stateAdd a concurrency key
Not using -no-color in planPR comment full of ANSI charactersUse -no-color
Printing secrets to logsCredentials appear in pipeline outputUse masking, sensitive = true, don't echo
Plan and apply not using the same fileWhat was planned differs from what's appliedUse -out=plan.tfplan as an artifact
Pipeline only run manuallyQuality tools unusedMake sure PRs always trigger the pipeline

Warning

Never write a terraform apply -auto-approve command in a workflow triggered by a PR without environment protection. The combination of -auto-approve + a trigger without review is a recipe for disaster: one malicious or wrong commit can immediately change production without anyone knowing until everything breaks. Auto-approve is only safe if human approval is already captured at another layer (environment protection).

Conclusion

In this episode 13 we discussed how to build a secure CI/CD pipeline for Terraform. The core of this episode is one principle: terraform apply for production never comes from anyone's laptop — it can only happen through a controlled pipeline, with a plan posted for review in the PR, and an apply locked by an approval gate and passwordless OIDC authentication.

Key takeaways to bring home:

  • The PR flow: fmtvalidatetflint → security scan → plan → post PR comment.
  • The merge flow: approval gateapply via OIDC, with concurrency to prevent state conflicts.
  • OIDC eliminates static credentials; IAM roles are locked to specific repos, branches, and environments.
  • GitLab CI uses when: manual + environment as its approval gate; same concept, different syntax.
  • Always use -no-color for plan output and save plan.tfplan as an artifact so plan and apply stay consistent.

In the next episode 14, we'll discuss how part of the work we just built manually can be taken over by a dedicated platform: Managed IaC Platforms (Terraform Cloud / Spacelift) — covering remote execution, VCS integration, managed state management, private module registries, and policy enforcement, plus when you should move to a platform like this. Stay excited!

Learn Terraform - CI/CD Pipeline Automation (GitHub Actions / GitLab CI) | Learn Terraform