Terraform CI/CD & Operations
When to Activate
- Setting up GitHub Actions (or other CI) to run
terraform planon pull requests - Configuring OIDC-based AWS authentication for CI (no stored credentials)
- Importing existing cloud resources under Terraform control
- Deciding between Terraform, Pulumi, and AWS CDK for a new project
- Looking up common Terraform CLI commands
- Reviewing anti-patterns (local state, secrets in tfvars, count=0 to disable)
For project structure, remote state setup, module design, workspace strategy, ECS/IAM patterns — see skill
terraform-patterns.
CI/CD Integration (GitHub Actions)
# .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
Importing Existing Resources
# 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"
Common Commands
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-Patterns
| 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 vs. Pulumi vs. AWS CDK — When to Use What
Terraform is the right choice in many situations, but modern IaC alternatives (Pulumi, AWS CDK) offer advantages worth evaluating for new projects.
Decision Matrix
| 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 | ✅ | ❌ | ❌ |
When to Stay with Terraform
- Existing large Terraform codebase — migration cost outweighs benefits
- Multi-cloud with providers not available in Pulumi (niche SaaS providers)
- Team is HCL-fluent and finds DSL simpler than TypeScript for infra
- Terraform Cloud governance (Sentinel policies, team VCS integration) is a requirement
- Working with providers only available via Terraform Registry
When to Choose Pulumi
- New project and team writes TypeScript/Python/Go daily — same language for infra and app
- Need real unit tests for infrastructure code (
pulumi.runtime.setMocks) - Multi-cloud: deploy to AWS + GCP + Azure + Kubernetes in one program
- Need complex dynamic resource generation (lists of services, conditional resource graphs)
- CrossGuard policy enforcement in CI is a requirement
When to Choose AWS CDK
- AWS-only infrastructure (no multi-cloud requirement)
- Team values strong defaults (L2 Constructs include encryption, proper IAM by default)
- Publishing reusable constructs to Construct Hub for other teams to consume
- Need CDK Pipelines for self-mutating CI/CD
- tighter AWS service integration and early access to new AWS services
Hybrid Strategy: Terraform + Pulumi/CDK
For large organizations, a hybrid works well:
- Terraform manages foundational infrastructure: VPCs, accounts, DNS, IAM org-wide roles
- Pulumi/CDK manages application-specific resources: ECS services, databases, queues
- Pulumi/CDK reads Terraform outputs via remote state (
terraform_remote_stateorpulumi.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[]),
},
});
Migration Path: Terraform → Pulumi
If migrating an existing Terraform codebase:
- Use
pulumi convert --from terraformto auto-convert HCL to TypeScript/Python - Import existing state with
pulumi import(matches existing resources without recreating) - Migrate module by module — keep Terraform for unmigrated modules, use Pulumi for new ones
- Run
pulumi refreshafter import to align state with actual cloud resources
# Convert existing Terraform module to Pulumi TypeScript
pulumi convert --from terraform --language typescript ./infra/modules/vpc
Infracost Cost Gate — Runnable CI Step
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).
Related Skills
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
Source: marvinrichter/clarc — distributed by TomeVault.