GitOps pipelines turn manual apply into a safe, documented flow. This episode builds a PR-to-production flow with the official setup-opentofu action on GitHub Actions and its counterpart on GitLab CI: fmt, validate, security scan, plan with PR comments, and apply via OIDC with an approval gate.

In the previous episode 12 we arranged a DRY architecture with Terragrunt: backends and providers centralized in the root file, while the Dev, Staging, and Prod folders only carry genuinely different values. That structure removes duplication, but it still leaves the most fatal weak point in IaC: who presses tofu apply. Manual apply from a laptop means the wrong environment, leaked credentials, and changes with zero trace in the repository. In the real world, this is the most common cause of drift and production incidents.
In episode 13 we'll build CI/CD pipeline automation: using the official opentofu/setup-opentofu action on GitHub Actions, then arranging a full GitOps flow — PR arrives, tofu fmt, tofu validate, security scan, tofu plan posting a comment to the PR, merge, approval gate, then tofu apply through OIDC. At the end we mirror it all in GitLab CI via .gitlab-ci.yml. Let's start.
Imagine a team of four, each with their own laptop and different AWS account. One person applies from an old branch, another forgets to run tofu init, and a third changes something directly in the console. All of that leaves a big question mark: what actually happened, by whom, and when? A pipeline turns those questions into definite answers, because the entire flow is recorded as build logs and only two things can trigger execution: repository events and documented human approval.
GitHub Actions officially provides the opentofu/setup-opentofu action — maintained by the OpenTofu team itself, not a third party. This action downloads the tofu binary at the requested version, verifies its checksum, and stores the download as a cache so subsequent jobs don't re-download it. Installing it is as simple as one step:
- name: Setup OpenTofu
uses: opentofu/setup-opentofu@v1
with:
tofu_version: 1.8.2
cache: true
github_token: ${{ secrets.GITHUB_TOKEN }}Tip
Pin the tofu_version explicitly in the workflow. OpenTofu minor versions evolve quickly, and determinism is non-negotiable for pipelines — a job whose behavior changes just because of an overnight release must never happen in an IaC repository.
Here's a workflow that summarizes the entire flow: a pull request triggers validate and security-scan, then plan posts a comment; a push to the main branch triggers apply running in an environment with an approval gate. Cloud credentials are never stored as long-lived secrets — every job receives a temporary AWS token via OIDC:
name: OpenTofu GitOps
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main]
permissions:
id-token: write
contents: read
pull-requests: write
env:
TF_IN_AUTOMATION: "1"
jobs:
validate:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup OpenTofu
uses: opentofu/setup-opentofu@v1
with:
tofu_version: 1.8.2
cache: true
- run: tofu fmt -check -recursive
- run: tofu init
- run: tofu validate
security-scan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkov scan
uses: bridgecrewio/checkov-action@v1
with:
directory: .
framework: tofu
- name: Trivy config scan
uses: aquasecurity/trivy-action@master
with:
scan-type: config
severity: HIGH,CRITICAL
exit-code: 1
plan:
needs: [validate, security-scan]
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup OpenTofu
uses: opentofu/setup-opentofu@v1
with:
tofu_version: 1.8.2
cache: true
- name: Assume role via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/opentofu-github-oidc
aws-region: ap-southeast-1
- name: Plan
run: |
tofu init
tofu plan -out=plan.tfplan
- name: Post plan comment to PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
tofu show -no-color plan.tfplan > plan.txt
gh pr comment "$PR_NUMBER" --body-file plan.txt
apply:
needs: [plan]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Setup OpenTofu
uses: opentofu/setup-opentofu@v1
with:
tofu_version: 1.8.2
cache: true
- name: Assume role via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/opentofu-github-oidc
aws-region: ap-southeast-1
- run: tofu init
- run: tofu apply -auto-approveNotice the strict role separation in this workflow. The validate and security-scan jobs have no cloud access at all — they purely check the code. The plan job is only entitled to use the OIDC role, and the apply job is hung on environment: production, which in the repository settings is configured to require reviewers — a human approval gate that nobody with push access can bypass.
Warning
Never put long-lived AWS credentials in as workflow secrets. With OIDC, the AWS role only trusts short-lived tokens from GitHub, and the apply job only runs after approval. Long-lived secrets in CI are one of the most common leak sources in IaC repositories.
GitLab CI offers the same concepts with different syntax: ordered stages, artifacts to pass plan files between jobs, rules to limit when jobs run, and id_tokens for OIDC. The apply job is manual and tied to the production environment, so it requires approval:
stages:
- validate
- plan
- apply
image: ghcr.io/opentofu/opentofu:1.8.2
variables:
TF_IN_AUTOMATION: "true"
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
tofu:validate:
stage: validate
script:
- tofu fmt -check -recursive
- tofu init
- tofu validate
tofu:plan:
stage: plan
id_tokens:
AWS_ID_TOKEN:
aud: https://gitlab.com
script:
- tofu init
- tofu plan -out=plan.tfplan
artifacts:
paths:
- plan.tfplan
expire_in: 1 week
tofu:apply:
stage: apply
id_tokens:
AWS_ID_TOKEN:
aud: https://gitlab.com
script:
- tofu init
- tofu apply plan.tfplan
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manualThe id_tokens block gives each job an OIDC token exchangeable for an AWS role, similar to the role of aws-actions/configure-aws-credentials on GitHub. The plan.tfplan artifact passes from the plan job to the apply job so what's executed is exactly what was reviewed — with no window between review and execution.
In episode 13 we built the automation foundation:
opentofu/setup-opentofu action with pinned versions and caching.tofu fmt, tofu validate, security scan, and tofu plan with PR comments; merge to main triggers apply.id_tokens and when: manual.Building your own pipeline gives full control, but not without maintenance costs. In the next episode, episode 14, we'll review the managed platform ecosystem — Spacelift, env0, Scalr — and get to know Digger, an open-source GitOps tool that executes OpenTofu directly inside GitHub Actions or GitLab CI without external SaaS. See you there!