| name | terraform-ci |
| description | 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. |
Terraform CI/CD & Operations
When to Activate
- Setting up GitHub Actions (or other CI) to run
terraform plan on 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)
name: Terraform
on:
pull_request:
paths: ['infrastructure/**']
push:
branches: [main]
paths: ['infrastructure/**']
jobs:
plan:
runs-on: ubuntu-latest
permissions:
id-token: write
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
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
terraform import aws_s3_bucket.uploads my-existing-bucket-name
terraform import aws_security_group.rds sg-0abc123def456
terraform plan
Common Commands
terraform init
terraform validate
terraform fmt -recursive
terraform plan
terraform apply
terraform apply -auto-approve
terraform destroy
terraform state list
terraform state show aws_db_instance.main
terraform output
terraform graph | dot -Tpng > graph.png
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_state or pulumi.StackReference)
const tfState = new pulumi.StackReference("tf-networking/prod");
const vpcId = tfState.getOutput("vpc_id");
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 terraform to 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 refresh after import to align state with actual cloud resources
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 IaC
kubernetes-patterns — Kubernetes resource management (Terraform can provision clusters, Helm provider manages workloads)
devsecops-patterns — Checkov, OPA/Conftest for IaC compliance scanning in CI
ci-cd-patterns — GitHub Actions integration for Terraform plan/apply pipelines