Cloud  /  Terraform

IaC Terraform 50 guides · updated 2026

Infrastructure as code done right — providers, state, reusable modules, and the workflow patterns that keep multi-cloud deployments sane in 2026.

CI/CD for Terraform

Automating Terraform through a CI/CD pipeline eliminates manual apply steps, enforces consistent processes, creates an audit trail, and enables the same code review discipline for infrastructure that teams use for application code.


Core Pipeline Model

The standard CI/CD model for Terraform follows this flow:

Developer pushes branch
PR created → CI: validate + fmt-check + plan
↓ (plan posted as PR comment)
Team reviews plan in PR
↓ (PR approved + merged to main)
CI: apply (saved plan from PR run)
State updated → Outputs available

GitHub Actions: Complete Pipeline

.github/workflows/terraform.yml
name: Terraform Pipeline
on:
pull_request:
branches: [main]
push:
branches: [main]
env:
TF_WORKING_DIR: ./infrastructure
TF_VERSION: "1.8.5"
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
terraform-check:
name: Validate & Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: terraform init (no backend)
run: terraform init -backend=false
working-directory: ${{ env.TF_WORKING_DIR }}
- name: terraform fmt
run: terraform fmt -check -recursive
working-directory: ${{ env.TF_WORKING_DIR }}
- name: terraform validate
run: terraform validate
working-directory: ${{ env.TF_WORKING_DIR }}
terraform-plan:
name: Plan
needs: terraform-check
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
outputs:
exitcode: ${{ steps.plan.outputs.exitcode }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_PLAN_ROLE_ARN }}
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- run: terraform init -input=false
working-directory: ${{ env.TF_WORKING_DIR }}
- name: terraform plan
id: plan
run: |
terraform plan \
-input=false \
-no-color \
-detailed-exitcode \
-out=tfplan \
2>&1 | tee plan.txt
echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
working-directory: ${{ env.TF_WORKING_DIR }}
continue-on-error: true
- name: Post plan to PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('${{ env.TF_WORKING_DIR }}/plan.txt', 'utf8');
const maxLen = 65000;
const body = `## Terraform Plan
\`\`\`
${plan.length > maxLen ? plan.slice(0, maxLen) + '\n...truncated' : plan}
\`\`\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
- name: Upload plan artifact
uses: actions/upload-artifact@v4
with:
name: tfplan-${{ github.sha }}
path: ${{ env.TF_WORKING_DIR }}/tfplan
retention-days: 5
terraform-apply:
name: Apply
needs: terraform-check
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
environment:
name: production
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_APPLY_ROLE_ARN }}
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- run: terraform init -input=false
working-directory: ${{ env.TF_WORKING_DIR }}
- name: terraform apply
run: terraform apply -input=false -auto-approve
working-directory: ${{ env.TF_WORKING_DIR }}

Atlantis: Pull Request Automation

Atlantis is an open-source self-hosted server that automates Terraform via PR comments:

atlantis.yaml
version: 3
projects:
- name: production
dir: infrastructure/environments/production
workspace: production
terraform_version: v1.8.5
autoplan:
when_modified: ["**/*.tf", "**/*.tfvars"]
enabled: true
apply_requirements:
- approved
- mergeable
- name: staging
dir: infrastructure/environments/staging
autoplan:
enabled: true

Team workflow:

# In PR comments:
atlantis plan -p production → Runs terraform plan, posts output
atlantis apply -p production → Applies after approval (if configured)

GitLab CI Pipeline

.gitlab-ci.yml
image:
name: hashicorp/terraform:1.8.5
entrypoint: [""]
variables:
TF_ROOT: ${CI_PROJECT_DIR}/infrastructure
cache:
paths:
- ${TF_ROOT}/.terraform
stages:
- validate
- plan
- apply
validate:
stage: validate
script:
- cd ${TF_ROOT}
- terraform init -backend=false
- terraform validate
- terraform fmt -check -recursive
plan:
stage: plan
script:
- cd ${TF_ROOT}
- terraform init -input=false
- terraform plan -out=tfplan -no-color | tee plan.txt
artifacts:
paths:
- ${TF_ROOT}/tfplan
- ${TF_ROOT}/plan.txt
expire_in: 7 days
only:
- merge_requests
apply:
stage: apply
script:
- cd ${TF_ROOT}
- terraform init -input=false
- terraform apply -input=false -auto-approve tfplan
dependencies:
- plan
when: manual # Require human click to apply
only:
- main

Drift Detection Pipeline

# Scheduled job: detect drift weekly
name: Terraform Drift Detection
on:
schedule:
- cron: '0 8 * * 1' # Every Monday at 8 AM
jobs:
drift-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_PLAN_ROLE_ARN }}
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- run: terraform init -input=false
- name: Check for drift
run: |
terraform plan -detailed-exitcode -no-color
if [ $? -eq 2 ]; then
echo "DRIFT DETECTED — infrastructure has changed outside Terraform"
exit 1
fi

Exit code 2 from terraform plan -detailed-exitcode means changes exist. This fires an alert when manual changes or external automation has drifted your infrastructure away from the declared state.


Environment Promotion Pattern

infrastructure/
├── environments/
│ ├── dev/ ← applies on every merge to main (auto)
│ ├── staging/ ← applies after dev succeeds + approval
│ └── production/ ← applies after staging + senior engineer approval

Each environment is a separate Terraform workspace with its own state file and apply pipeline stage — promoting configuration from dev → staging → production with automated gates.