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.

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.
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:
| Problem | With Laptop Credentials | With Pipeline + OIDC |
|---|---|---|
| Audit trail | No trace of who applied and when | Every run is recorded: commit, user, timestamp |
| Laptop theft/attack | Production credentials leak along with it | No permanent credentials to steal |
| Human error | One typo = production down | Plan + review + approval before execution |
| Least privilege principle | Developer holds full access | Specific 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.
Before writing YAML, you must understand the flow pattern first. The Terraform pipeline in professional teams is always split into two big tracks:
| Stage | Trigger | Tool | Purpose |
|---|---|---|---|
| 1. Formatting | Pull Request | terraform fmt -check | Ensuring uniform code style |
| 2. Validation | Pull Request | terraform validate | Ensuring valid syntax & references |
| 3. Linting | Pull Request | tflint | Detecting provider-specific errors |
| 4. Security Scan | Pull Request | checkov / trivy | Detecting security misconfigurations |
| 5. Plan | Pull Request | terraform plan | Previewing changes + posting to the PR |
| 6. Apply | Merge to main | terraform apply | Real 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.
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:
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 }}
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:
{
"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.
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:
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=falseThree 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.
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:
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: manualThe 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.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.
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: productionA concise comparison table:
| Aspect | GitHub Actions | GitLab CI |
|---|---|---|
| PR trigger | on: pull_request | rules + $CI_PIPELINE_SOURCE |
| Approval gate | Environment protection (required reviewers) | when: manual |
| State locking between runs | concurrency | Environment deployment_tier / lock |
| Plan → Apply consistency | Store the plan file as an artifact | Artifacts + terraform plan -out |
| OIDC to AWS | configure-aws-credentials | id_tokens + aws cli / oidc config |
Finally, let's talk about the traps that most often make IaC pipelines problematic:
| Mistake | Symptom | Solution |
|---|---|---|
| Production credentials on laptops / static secrets | Leaks when the laptop is stolen, hard to rotate | OIDC + role per environment |
Forgetting id-token: write | configure-aws-credentials job errors 401 | Add OIDC permission to the job |
| Apply without an approval gate | Changes can happen without review | Environment protection / when: manual |
No concurrency | Two parallel applies overwrite state | Add a concurrency key |
Not using -no-color in plan | PR comment full of ANSI characters | Use -no-color |
| Printing secrets to logs | Credentials appear in pipeline output | Use masking, sensitive = true, don't echo |
| Plan and apply not using the same file | What was planned differs from what's applied | Use -out=plan.tfplan as an artifact |
| Pipeline only run manually | Quality tools unused | Make 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).
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:
fmt → validate → tflint → security scan → plan → post PR comment.apply via OIDC, with concurrency to prevent state conflicts.when: manual + environment as its approval gate; same concept, different syntax.-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!