Terraform in CI/CD — plan on PR, apply on merge, OIDC auth, drift detection, importing existing resources, common CLI commands, anti-patterns, and Terraform vs Pulumi vs CDK decision guide.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
terraform plan on pull requestsFor project structure, remote state setup, module design, workspace strategy, ECS/IAM patterns — see skill
terraform-patterns.
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request:
paths: ['infrastructure/**']
push:
branches: [main]
paths: ['infrastructure/**']
jobs:
plan:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC for AWS auth (no stored credentials)
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/github-terraform
aws-region: eu-west-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: '~1.14'
- name: Terraform Init
run: terraform init
working-directory: infrastructure/environments/prod
- name: Terraform Validate
run: terraform validate
working-directory: infrastructure/environments/prod
- name: Terraform Plan
id: plan
run: |
terraform plan -var="db_password=${{ secrets.DB_PASSWORD }}" \
-out=plan.tfplan -no-color 2>&1 | tee plan.txt
working-directory: infrastructure/environments/prod
- name: Comment Plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const plan = require('fs').readFileSync('infrastructure/environments/prod/plan.txt', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `\`\`\`hcl\n${plan.slice(0, 65000)}\n\`\`\``
});
apply:
needs: plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # requires manual approval in GitHub
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/github-terraform
aws-region: eu-west-1
- uses: hashicorp/setup-terraform@v3
- run: terraform init
working-directory: infrastructure/environments/prod
- run: terraform apply -auto-approve -var="db_password=${{ secrets.DB_PASSWORD }}"
working-directory: infrastructure/environments/prod
# Bring existing AWS resource under Terraform control
terraform import aws_s3_bucket.uploads my-existing-bucket-name
terraform import aws_security_group.rds sg-0abc123def456
# After import: run plan to check drift
terraform plan
# Fix .tf files until plan shows "No changes"
terraform init # download providers + configure backend
terraform validate # check syntax and references
terraform fmt -recursive # format all .tf files
terraform plan # preview changes (never modifies infra)
terraform apply # apply the plan (prompts for confirmation)
terraform apply -auto-approve # skip confirmation (CI only)
terraform destroy # destroy everything (use with extreme care)
terraform state list # list all managed resources
terraform state show aws_db_instance.main # inspect state of one resource
terraform output # print outputs
terraform graph | dot -Tpng > graph.png # visualize dependency graph
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Local state (no backend) | Lost on disk failure, no team sharing | S3 + DynamoDB locking from day 1 |
Secrets in .tfvars committed to git | Credential exposure | Pass via CI env vars or Secrets Manager |
count = 0 to "disable" resources | Leaves dangling state | Remove the resource block + terraform state rm |
| Manual changes in console | State drift — next plan tries to revert | All changes through Terraform |
No deletion_protection on prod DB | Accidental destroy deletes production data | Always set on prod databases |
One giant main.tf | Impossible to navigate | Split by concern: vpc.tf, rds.tf, ecs.tf |
depends_on overuse | Hides missing implicit references | Fix the reference chain instead |
ignore_changes for everything | Terraform loses awareness of drift | Only ignore what autoscaling legitimately changes |
Terraform is the right choice in many situations, but modern IaC alternatives (Pulumi, AWS CDK) offer advantages worth evaluating for new projects.
| Criterion | Terraform HCL | Pulumi | AWS CDK |
|---|---|---|---|
| Multi-cloud support | ✅ Best (3000+ providers) | ✅ Good | ❌ AWS only |
| Real programming language | ❌ HCL DSL | ✅ TS/Python/Go | ✅ TS/Python/Java |
| Unit testing infrastructure | ❌ | ✅ | ✅ |
| Type safety + IDE autocomplete | ❌ | ✅ | ✅ |
| Native loops & conditionals | ⚠️ count/for_each | ✅ | ✅ |
| Existing Terraform state migration | ✅ N/A | ⚠️ Via cdktf | ❌ |
| Provider ecosystem maturity | ✅ Largest | ✅ Uses TF providers | ⚠️ AWS-specific |
| Reusable abstractions | Modules | ComponentResource | L3 Constructs |
| Governance / policy | Sentinel / OPA | CrossGuard | CDK Aspects + OPA |
| Team HCL familiarity | ✅ | ❌ | ❌ |
pulumi.runtime.setMocks)For large organizations, a hybrid works well:
terraform_remote_state or pulumi.StackReference)// Pulumi: read VPC from existing Terraform state
const tfState = new pulumi.StackReference("tf-networking/prod");
const vpcId = tfState.getOutput("vpc_id");
// Use VPC created by Terraform in a Pulumi resource
const service = new aws.ecs.Service("api", {
networkConfiguration: {
subnets: tfState.getOutput("private_subnet_ids").apply(ids => ids as string[]),
},
});
If migrating an existing Terraform codebase:
pulumi convert --from terraform to auto-convert HCL to TypeScript/Pythonpulumi import (matches existing resources without recreating)pulumi refresh after import to align state with actual cloud resources# Convert existing Terraform module to Pulumi TypeScript
pulumi convert --from terraform --language typescript ./infra/modules/vpc
Add this step to your plan job (after Terraform Plan, before Comment Plan on PR) to block PRs that increase monthly cloud costs by more than 15%:
- name: Install Infracost
run: |
curl -fsSL https://raw.githubusercontent.com/infracost/infracost/master/scripts/install.sh | sh
env:
INFRACOST_API_KEY: ${{ secrets.INFRACOST_API_KEY }}
- name: Run Infracost diff
id: infracost
run: |
infracost diff \
--path infrastructure/environments/prod \
--compare-to main \
--format json \
--out-file /tmp/infracost.json
# Extract percentage change (positive = cost increase)
PCT=$(jq '.diffTotalMonthlyCost | tonumber' /tmp/infracost.json 2>/dev/null || echo "0")
echo "cost_delta_pct=$PCT" >> "$GITHUB_OUTPUT"
working-directory: .
- name: Enforce cost threshold
run: |
DELTA="${{ steps.infracost.outputs.cost_delta_pct }}"
echo "Monthly cost delta: $DELTA USD"
# Block if increase > $500/month; tag PR for cost-review if > $50
if (( $(echo "$DELTA > 500" | bc -l) )); then
echo "::error::Monthly cost increase \$$DELTA exceeds \$500 threshold — add 'cost-approved' label to override"
exit 1
elif (( $(echo "$DELTA > 50" | bc -l) )); then
gh pr edit "${{ github.event.pull_request.number }}" --add-label "cost-review"
echo "::warning::Monthly cost increase \$$DELTA — tagged for cost-review"
fi
Prerequisites: INFRACOST_API_KEY secret set in GitHub repo settings (free at infracost.io).
iac-modern-patterns — Pulumi TypeScript, AWS CDK L1/L2/L3, Bicep, cdktf — full reference for non-HCL IaCkubernetes-patterns — Kubernetes resource management (Terraform can provision clusters, Helm provider manages workloads)devsecops-patterns — Checkov, OPA/Conftest for IaC compliance scanning in CIci-cd-patterns — GitHub Actions integration for Terraform plan/apply pipelines