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 availableGitHub Actions: Complete Pipeline
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:
version: 3projects: - 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: trueTeam workflow:
# In PR comments:atlantis plan -p production → Runs terraform plan, posts outputatlantis apply -p production → Applies after approval (if configured)GitLab CI Pipeline
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: - mainDrift Detection Pipeline
# Scheduled job: detect drift weeklyname: Terraform Drift Detectionon: 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 fiExit 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 approvalEach environment is a separate Terraform workspace with its own state file and apply pipeline stage — promoting configuration from dev → staging → production with automated gates.