Integrating Terraform into CI/CD Pipelines: From Local Runs to Automated Deployments
Your complete guide to automating Terraform in CI/CD systems—transforming infrastructure changes from manual operations into fully automated, gated, and auditable software delivery pipelines.
📅 Published: Feb 2026
⏱️ Estimated Reading Time: 26 minutes
🏷️ Tags: Terraform, CI/CD, GitHub Actions, GitLab CI, Jenkins, Infrastructure Automation, DevOps
🔄 Introduction: Why CI/CD for Infrastructure?
The Evolution of Infrastructure Delivery
First generation: ClickOps. You log into the AWS console, click buttons, and hope you remember everything you did. Repeatable? No. Auditable? No. Scalable? Absolutely not.
Second generation: Local Terraform. You write configuration, run terraform apply from your laptop, and commit the code later. Better, but:
❌ "Works on my machine" becomes "works on my AWS credentials"
❌ Who applied what, when, and why? Git history doesn't show runs
❌ One person's
~/.aws/credentialsbecomes a single point of failure❌ No gated approvals—just you and your terminal
Third generation: CI/CD pipelines. Infrastructure changes are proposed, reviewed, planned, approved, and applied—all through automated, auditable workflows.
Git Push → Pull Request → Terraform Plan → Review → Merge → Terraform Apply
This is the DevOps end state for infrastructure. Not because it's trendy, but because it's safer, faster, and more reliable than any alternative.
The CI/CD Value Proposition for Terraform
| Aspect | Local Runs | CI/CD Pipeline |
|---|---|---|
| Consistency | Depends on local environment | Identical every time |
| Auditability | Who ran it? When? | Full logs, traceability |
| Collaboration | "Can you run this for me?" | PR-based reviews |
| Security | Long-lived keys on laptops | Ephemeral, OIDC credentials |
| Speed | As fast as your laptop | Parallel, optimized runners |
| Confidence | "I think this is right" | Verified by tests + approval gates |
The goal isn't to remove humans from the process—it's to elevate humans to the right level of decision-making. Review plans, not command syntax. Approve changes, not copy-pasting credentials.
🏗️ Pipeline Architecture Patterns
Pattern 1: Branch-Based Environments
Each branch maps to an environment. The most common pattern.
Characteristics:
✅ Simple, easy to understand
✅ Environment parity (main = staging)
✅ Clear promotion path
❌ Requires separate configurations per environment
Pattern 2: Workspace-Per-Environment
Single configuration, multiple Terraform Cloud workspaces.
Characteristics:
✅ Single source of truth for configuration
✅ No copy-paste between environments
✅ Terraform Cloud workspaces handle state isolation
❌ Workspace promotion requires careful orchestration
❌ Conditional logic in configuration (terraform.workspace)
Pattern 3: Infrastructure Monorepo
All infrastructure components in one repository, deployed independently.
infrastructure/
├── modules/ # Shared modules
├── networking/ # VPC, subnets, etc.
│ ├── dev/
│ └── prod/
├── security/ # IAM, KMS, etc.
│ ├── dev/
│ └── prod/
├── data/ # RDS, ElastiCache, etc.
│ ├── dev/
│ └── prod/
└── applications/ # ECS, EKS, Lambda
├── team-a/
└── team-b/Characteristics:
✅ Atomic changes across components
✅ Clear ownership via CODEOWNERS
✅ Single versioning for infrastructure
❌ Complex CI/CD (detect changes, run selectively)
❌ Scaling limits (50-100 components)
Change detection:
#!/bin/bash # detect-changes.sh # Only run Terraform in directories that changed CHANGED_DIRS=$(git diff --name-only origin/main | cut -d/ -f1-2 | sort -u) for dir in networking security data applications; do if echo "$CHANGED_DIRS" | grep -q "^$dir/"; then echo "Changes detected in $dir" cd "$dir/prod" && terraform plan fi done
🧰 CI/CD Platform Deep Dives
GitHub Actions: Native Integration
GitHub Actions is the most popular CI/CD platform for Terraform, with native integration via the HashiCorp setup-terraform action.
# .github/workflows/terraform.yml name: Terraform CI/CD on: pull_request: branches: [ main, staging ] paths: - '**.tf' - '**.tfvars' - 'modules/**' push: branches: [ main ] paths: - '**.tf' - '**.tfvars' - 'modules/**' permissions: contents: read pull-requests: write id-token: write # For OIDC jobs: terraform: name: Terraform runs-on: ubuntu-latest # Map environment based on branch environment: ${{ github.ref_name == 'main' && 'prod' || 'dev' }} defaults: run: working-directory: ./environments/${{ github.ref_name == 'main' && 'prod' || 'dev' }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Terraform uses: hashicorp/setup-terraform@v3 with: terraform_version: 1.6.0 terraform_wrapper: true # Enables plan output capture - name: Configure AWS Credentials (OIDC) uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/terraform-${{ github.ref_name == 'main' && 'prod' || 'dev' }} aws-region: us-west-2 role-session-name: terraform-github-actions - name: Terraform Init id: init run: terraform init continue-on-error: false - name: Terraform Format id: fmt run: terraform fmt -check -recursive working-directory: ./ continue-on-error: true # Don't fail pipeline, just warn - name: Terraform Validate id: validate run: terraform validate continue-on-error: false - name: Terraform Plan id: plan run: terraform plan -no-color -out=tfplan continue-on-error: false - name: Upload Plan Artifact uses: actions/upload-artifact@v3 with: name: tfplan-${{ github.run_id }} path: ./environments/${{ github.ref_name == 'main' && 'prod' || 'dev' }}/tfplan - name: Comment Plan on PR if: github.event_name == 'pull_request' uses: actions/github-script@v6 with: script: | const output = `#### Terraform Plan 📖 <details><summary>Show Plan</summary> \`\`\`terraform\n ${process.env.PLAN_OUTPUT} \`\`\` </details> *Pushed by: @${{ github.actor }}* *Workflow: \`${{ github.workflow }}\`* *Working directory: \`${{ steps.plan.outputs.working-directory }}\`*`; github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: output }); env: PLAN_OUTPUT: ${{ steps.plan.outputs.stdout }} - name: Terraform Apply if: github.ref == 'refs/heads/main' && github.event_name == 'push' run: terraform apply -auto-approve tfplan continue-on-error: false
Key GitHub Actions features for Terraform:
| Feature | Implementation | Benefit |
|---|---|---|
| OIDC | aws-actions/configure-aws-credentials | No long-lived secrets |
| Plan comments | actions/github-script | Review infrastructure in PR |
| Path filtering | paths: in trigger | Only run when relevant |
| Environment protection | environment: field | Manual approvals, branch restrictions |
| Artifact sharing | upload-artifact | Promote plan from PR to merge |
GitLab CI: Built-In Terraform Support
GitLab CI includes native Terraform integration with dedicated keywords and templates.
# .gitlab-ci.yml image: hashicorp/terraform:1.6 cache: key: "${CI_COMMIT_REF_SLUG}" paths: - ${CI_PROJECT_DIR}/environments/dev/.terraform - ${CI_PROJECT_DIR}/environments/prod/.terraform variables: TF_ROOT: ${CI_PROJECT_DIR}/environments/${CI_ENVIRONMENT_NAME} TF_IN_AUTOMATION: "true" stages: - validate - test - plan - deploy # Template for Terraform jobs .terraform-base: before_script: - cd ${TF_ROOT} - terraform init artifacts: paths: - ${TF_ROOT}/tfplan reports: terraform: ${TF_ROOT}/tfplan.json validate: stage: validate extends: .terraform-base script: - terraform validate - terraform fmt -check -recursive plan: stage: plan extends: .terraform-base script: - terraform plan -out=tfplan -no-color - terraform show -json tfplan > tfplan.json environment: name: $CI_ENVIRONMENT_NAME action: prepare artifacts: paths: - ${TF_ROOT}/tfplan reports: terraform: ${TF_ROOT}/tfplan.json deploy: stage: deploy extends: .terraform-base script: - terraform apply -auto-approve tfplan environment: name: $CI_ENVIRONMENT_NAME action: start rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: manual allow_failure: false - if: $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH when: never # Environment-specific jobs plan:dev: extends: plan variables: CI_ENVIRONMENT_NAME: dev rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" changes: - environments/dev/**/* - modules/**/* deploy:dev: extends: deploy variables: CI_ENVIRONMENT_NAME: dev rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH changes: - environments/dev/**/* - modules/**/* plan:prod: extends: plan variables: CI_ENVIRONMENT_NAME: prod rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" changes: - environments/prod/**/* - modules/**/* - when: never deploy:prod: extends: deploy variables: CI_ENVIRONMENT_NAME: prod rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH changes: - environments/prod/**/* - modules/**/* - when: manual allow_failure: false
GitLab CI Terraform reports in merge requests:
terraform-report: stage: plan script: - terraform init - terraform plan -out=tfplan - terraform show -json tfplan > tfplan.json artifacts: reports: terraform: tfplan.json
GitLab CI will automatically display the plan summary in the merge request widget.
Jenkins: Customizable Pipelines
Jenkins offers maximum flexibility for complex enterprise workflows.
// Jenkinsfile (Declarative Pipeline) pipeline { agent any parameters { choice( name: 'ENVIRONMENT', choices: ['dev', 'staging', 'prod'], description: 'Target environment' ) choice( name: 'ACTION', choices: ['plan', 'apply', 'destroy'], description: 'Terraform action' ) } environment { TF_IN_AUTOMATION = 'true' TF_ROOT = "${WORKSPACE}/environments/${params.ENVIRONMENT}" AWS_REGION = 'us-west-2' } stages { stage('Checkout') { steps { checkout scm } } stage('AWS Authentication') { steps { withAWS( region: 'us-west-2', role: "arn:aws:iam::${getAccountId(params.ENVIRONMENT)}:role/terraform-jenkins", roleSessionName: 'terraform-pipeline' ) { script { env.AWS_ACCESS_KEY_ID = AWS_ACCESS_KEY_ID env.AWS_SECRET_ACCESS_KEY = AWS_SECRET_ACCESS_KEY env.AWS_SESSION_TOKEN = AWS_SESSION_TOKEN } } } } stage('Terraform Init') { steps { dir(env.TF_ROOT) { sh 'terraform init' } } } stage('Terraform Validate') { steps { dir(env.TF_ROOT) { sh 'terraform validate' sh 'terraform fmt -check -recursive' } } } stage('Terraform Plan') { when { expression { params.ACTION == 'plan' || params.ACTION == 'apply' } } steps { dir(env.TF_ROOT) { sh 'terraform plan -no-color -out=tfplan' sh 'terraform show -no-color tfplan > plan.txt' } } post { success { archiveArtifacts artifacts: "${env.TF_ROOT}/tfplan" recordIssues( tools: [terraform( pattern: "${env.TF_ROOT}/plan.txt")], qualityGates: [[threshold: 1, type: 'TOTAL', unstable: true]] ) } } } stage('Approval') { when { expression { params.ACTION == 'apply' && params.ENVIRONMENT == 'prod' } } steps { input message: 'Apply to production?', ok: 'Deploy' } } stage('Terraform Apply') { when { expression { params.ACTION == 'apply' } } steps { dir(env.TF_ROOT) { sh 'terraform apply -auto-approve tfplan' } } } stage('Terraform Destroy') { when { expression { params.ACTION == 'destroy' } } steps { input message: "Destroy ${params.ENVIRONMENT} infrastructure?", ok: 'Destroy' dir(env.TF_ROOT) { sh 'terraform destroy -auto-approve' } } } } post { always { cleanWs() } failure { emailext( subject: "FAILED: ${env.JOB_NAME} - ${env.BUILD_NUMBER}", body: "Pipeline failed. Check logs: ${env.BUILD_URL}", to: 'team@example.com' ) } } } def getAccountId(environment) { def accounts = [ 'dev': '123456789012', 'staging': '123456789012', 'prod': '210987654321' ] return accounts[environment] }
🔐 Security in CI/CD Pipelines
Never Store Secrets in CI/CD Variables
❌ BAD: Long-lived access keys in CI/CD secrets
# ❌ Never do this AWS_ACCESS_KEY_ID: AKIA1234567890 AWS_SECRET_ACCESS_KEY: abcdefghijklmnopqrstuvwxyz1234
✅ GOOD: OIDC authentication (AWS, GCP, Azure)
# GitHub Actions - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/terraform-github-actions aws-region: us-west-2 role-session-name: terraform-${{ github.run_id }}
GitLab CI OIDC:
# GitLab CI variables: AWS_ROLE_ARN: arn:aws:iam::123456789012:role/terraform-gitlab AWS_REGION: us-west-2 before_script: - apt-get update && apt-get install -y awscli - export AWS_WEB_IDENTITY_TOKEN_FILE=$(pwd)/web-identity-token - echo $CI_JOB_JWT_V2 > $AWS_WEB_IDENTITY_TOKEN_FILE - export AWS_ROLE_SESSION_NAME=terraform-$CI_PIPELINE_ID - aws sts assume-role-with-web-identity ...
Jenkins OIDC with AWS:
// Jenkins with OIDC plugin withAWS( region: 'us-west-2', role: 'arn:aws:iam::123456789012:role/terraform-jenkins', roleSessionName: "terraform-${env.BUILD_NUMBER}", webIdentityTokenFile: '/var/run/secrets/eks.amazonaws.com/serviceaccount/token' ) { sh 'terraform init' }
Least Privilege IAM Roles
Create dedicated IAM roles per environment and per component:
# IAM role for CI/CD pipeline resource "aws_iam_role" "terraform_ci" { name = "terraform-github-actions-${var.environment}" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Principal = { Federated = "arn:aws:iam::${var.account_id}:oidc-provider/token.actions.githubusercontent.com" } Action = "sts:AssumeRoleWithWebIdentity" Condition = { StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" } StringLike = { "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:*" } } } ] }) } # Component-specific permissions resource "aws_iam_role_policy" "networking" { name = "networking-permissions" role = aws_iam_role.terraform_ci.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "ec2:CreateVpc", "ec2:DeleteVpc", "ec2:DescribeVpcs", "ec2:CreateSubnet", "ec2:DeleteSubnet", "ec2:DescribeSubnets", # ... only what networking needs ] Resource = "*" } ] }) }
Plan Artifact Security
Terraform plan files can contain sensitive information. Treat them as secrets.
# GitHub Actions: Encrypt plan artifacts - name: Encrypt Plan run: | gpg --symmetric --cipher-algo AES256 --batch --passphrase "${{ secrets.PLAN_PASSPHRASE }}" tfplan if: github.event_name == 'pull_request' - name: Upload Encrypted Plan uses: actions/upload-artifact@v3 with: name: tfplan-${{ github.run_id }}.gpg path: tfplan.gpg if: github.event_name == 'pull_request'
# Decrypt and apply - name: Decrypt Plan run: | gpg --decrypt --batch --passphrase "${{ secrets.PLAN_PASSPHRASE }}" tfplan.gpg > tfplan if: github.ref == 'refs/heads/main' && github.event_name == 'push'
🧪 Testing in Pipelines
Multi-Stage Testing Pipeline
name: Complete Terraform Pipeline on: pull_request jobs: static-analysis: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 - name: fmt run: terraform fmt -check -recursive - name: init run: terraform init -backend=false - name: validate run: terraform validate - name: tflint uses: terraform-linters/setup-tflint@v3 run: tflint --recursive - name: checkov uses: bridgecrewio/checkov-action@master with: directory: ./ framework: terraform unit-tests: runs-on: ubuntu-latest needs: static-analysis steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 - name: Terraform Test run: terraform test -verbose working-directory: ./test/unit integration-tests: runs-on: ubuntu-latest needs: unit-tests environment: test steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 - name: Configure AWS (Test Account) uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/terraform-test aws-region: us-west-2 - name: Terraform Apply (Test) run: | cd test/environments/integration terraform init terraform apply -auto-approve -var="test_id=${{ github.run_id }}" - name: Verify Resources run: | # Custom verification scripts ./test/verify.sh - name: Terraform Destroy if: always() run: | cd test/environments/integration terraform destroy -auto-approve -var="test_id=${{ github.run_id }}" plan: runs-on: ubuntu-latest needs: integration-tests environment: ${{ github.base_ref == 'main' && 'prod' || 'dev' }} steps: # ... standard plan steps
📊 Advanced CI/CD Patterns
Pattern 1: Plan Promotion
Generate plan in PR, apply same plan on merge.
# Pull Request: Generate and upload plan - name: Terraform Plan run: terraform plan -no-color -out=tfplan - name: Upload Plan uses: actions/upload-artifact@v3 with: name: tfplan-${{ github.sha }} path: tfplan # Push to main: Download and apply same plan - name: Download Plan uses: actions/download-artifact@v3 with: name: tfplan-${{ github.sha }} - name: Terraform Apply run: terraform apply -auto-approve tfplan
Benefits:
✅ Exact same plan applied as was reviewed
✅ No configuration drift between plan and apply
✅ No second plan run that might show different results
Pattern 2: Drift Detection
Regularly scan for manual changes and alert.
name: Drift Detection on: schedule: - cron: '0 */6 * * *' # Every 6 hours workflow_dispatch: # Manual trigger jobs: detect-drift: runs-on: ubuntu-latest environment: prod steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 - name: Configure AWS uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/terraform-drift-detection - name: Terraform Init run: terraform init working-directory: ./environments/prod - name: Terraform Plan id: plan run: terraform plan -no-color working-directory: ./environments/prod continue-on-error: false - name: Check for Drift if: steps.plan.outputs.stdout != 'No changes.' run: | echo "❌ Drift detected in production!" echo "${{ steps.plan.outputs.stdout }}" exit 1
Pattern 3: Multi-Account/Region Deployments
Parallel deployments across multiple targets.
name: Multi-Region Deployment on: push: branches: [ main ] jobs: deploy-region: strategy: matrix: region: [us-west-2, us-east-1, eu-west-1] runs-on: ubuntu-latest environment: prod-${{ matrix.region }} steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 - name: Configure AWS (${{ matrix.region }}) uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/terraform-prod aws-region: ${{ matrix.region }} - name: Terraform Init run: terraform init working-directory: ./environments/prod env: TF_VAR_region: ${{ matrix.region }} - name: Terraform Apply run: terraform apply -auto-approve working-directory: ./environments/prod env: TF_VAR_region: ${{ matrix.region }}
Pattern 4: Dependent Stack Ordering
Deploy stacks in dependency order.
jobs: deploy-networking: steps: - name: Deploy Networking run: | cd stacks/networking terraform init terraform apply -auto-approve outputs: vpc_id: ${{ steps.networking.outputs.vpc_id }} deploy-security: needs: deploy-networking steps: - name: Deploy Security Groups run: | cd stacks/security terraform init terraform apply -auto-approve -var="vpc_id=${{ needs.deploy-networking.outputs.vpc_id }}" deploy-application: needs: [deploy-networking, deploy-security] steps: - name: Deploy Application run: | cd stacks/application terraform init terraform apply -auto-approve \ -var="vpc_id=${{ needs.deploy-networking.outputs.vpc_id }}" \ -var="security_group_id=${{ needs.deploy-security.outputs.sg_id }}"
🚨 Pipeline Failure Scenarios and Recovery
Scenario 1: Plan Succeeded, Apply Failed
Symptom: Terraform plan generated successfully, but apply failed mid-way.
Root causes:
API rate limits exceeded
Resource constraints (insufficient capacity)
Temporary network issues
IAM permission inconsistencies
Recovery script:
- name: Terraform Apply with Retry id: apply uses: nick-invision/retry@v2 with: timeout_minutes: 30 max_attempts: 3 retry_on: error command: terraform apply -auto-approve tfplan working-directory: ./environments/prod - name: Notify on Failure if: steps.apply.outcome == 'failure' run: | curl -X POST -H 'Content-type: application/json' \ --data '{"text":"❌ Terraform apply failed in production!"}' \ ${{ secrets.SLACK_WEBHOOK }}
Scenario 2: State Lock Contention
Symptom: Pipeline fails with "Error acquiring the state lock".
Root causes:
Concurrent pipeline runs
Previous run crashed without releasing lock
Manual Terraform operation in progress
Solution:
- name: Check for Existing Lock id: lock-check run: | # Attempt to acquire lock with timeout if ! terraform plan -lock-timeout=60s -out=tfplan; then echo "State is locked. Waiting 5 minutes and retrying..." sleep 300 terraform plan -lock-timeout=60s -out=tfplan fi working-directory: ./environments/prod continue-on-error: true - name: Force Unlock (with notification) if: steps.lock-check.outcome == 'failure' run: | echo "⚠️ State lock could not be acquired after retry" echo "Manual intervention required" exit 1
Scenario 3: Drift During Pipeline Execution
Symptom: Plan shows changes, but apply fails because resource was modified during pipeline.
Solution:
- name: Refresh State Before Apply run: terraform apply -refresh-only -auto-approve working-directory: ./environments/prod - name: Generate Fresh Plan run: terraform plan -out=tfplan working-directory: ./environments/prod - name: Apply run: terraform apply -auto-approve tfplan working-directory: ./environments/prod
📋 CI/CD Pipeline Checklist
Pipeline Design
Branch strategy defined — Which branches map to which environments?
Path filtering configured — Only run when relevant files change
Dependency ordering — Stacks deployed in correct sequence
Parallel execution — Independent stacks deploy concurrently
Idempotency — Multiple runs produce same result
Security
No long-lived credentials — OIDC or short-lived tokens only
Least privilege IAM — Dedicated roles per environment/component
Secrets never exposed — Sensitive variables masked in logs
Plan artifacts encrypted — If stored between stages
Pipeline permissions minimized — Read-only except where needed
Testing
Static analysis — fmt, validate, tflint, checkov
Unit tests — terraform test or Terratest
Integration tests — Isolated test environment
Plan review — PR comments with plan output
Drift detection — Scheduled scans for manual changes
Deployment
Approval gates — Manual approval for production
Plan promotion — Same plan applied as reviewed
Rollback strategy — Previous state version accessible
Notifications — Slack/Teams/Email on failures
Audit trail — All actions logged and traceable
Recovery
Retry logic — Transient failures automatically retry
State lock handling — Timeout and retry strategy
Failure notifications — Immediate alert on pipeline failure
Runbook — Documented procedures for common failures
Post-mortem process — Learn from pipeline incidents
🎓 Summary: From Manual to Automated
The journey from local Terraform to full CI/CD automation:
| Phase | Characteristics | When You're Ready |
|---|---|---|
| 1. Local | terraform apply from laptop, state in Git | Never. Skip this phase. |
| 2. Remote State | State in S3/GCS/Azure, still local runs | Day 1 |
| 3. Manual CI | CI runs plan, human runs apply | Week 1 |
| 4. Automated Plan | CI runs plan on PRs, posts comments | Week 2 |
| 5. Automated Apply (Non-Prod) | CI auto-applies to dev/staging | Week 3 |
| 6. Gated Apply (Prod) | CI plans, human approves, CI applies | Month 2 |
| 7. Full Automation | Promotion pipelines, drift detection, policy as code | Month 3+ |
The goal isn't to remove humans—it's to elevate them. Instead of worrying about command syntax and AWS credentials, your team reviews plans, approves changes, and designs better architectures.
🔗 Master Terraform CI/CD with Hands-on Labs
Theory is essential, but CI/CD pipelines are learned through building and debugging real workflows.
👉 Practice Terraform CI/CD integration with GitHub Actions, GitLab CI, and Jenkins in our interactive labs at:
https://devops.trainwithsky.com/
Our platform provides:
Real GitHub/GitLab repositories to configure
OIDC setup exercises
Multi-environment pipeline challenges
Failure scenario recovery drills
Production promotion workflows
Frequently Asked Questions
Q: Should I run terraform plan on every commit or only on PRs?
A: Both. On PRs, plan provides review context. On pushes to main, plan can detect drift before apply. Some teams also run scheduled plan to detect manual changes.
Q: How do I handle secrets in CI/CD pipelines?
A: Three-layer approach:
Never store secrets — Use OIDC for cloud provider auth
Use run-time retrieval — Pull secrets from Vault/AWS Secrets Manager at apply time
Mask in logs — Mark variables as sensitive, configure log redaction
Q: How long should a Terraform pipeline take?
A:
Static analysis: < 1 minute
Plan: 1-5 minutes (depends on state size)
Apply (non-prod): 2-15 minutes
Apply (prod): 5-30 minutes
If your pipelines are slower, consider splitting state or optimizing provider operations.
Q: How do I test destructive changes in CI/CD?
A: Never test destructive changes in production. Use ephemeral environments:
Create temporary workspace/branch
Apply configuration
Run validation tests
Destroy everything
Repeat in production only after validation
Q: What's the best CI/CD platform for Terraform?
A: There's no single answer:
GitHub Actions: Best for GitHub users, native integration, simple syntax
GitLab CI: Best for GitLab users, built-in Terraform reports
Jenkins: Best for complex enterprise workflows, maximum flexibility
Terraform Cloud/Enterprise: Best for teams already using HashiCorp stack
Q: How do I prevent concurrent applies to the same state?
A: Use state locking (DynamoDB for S3, native for TFC). In CI/CD, use concurrency controls:
# GitHub Actions concurrency: group: terraform-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false # GitLab CI resource_group: production # Jenkins lock(resource: 'terraform-state-production') { sh 'terraform apply' }
Q: Should I use -auto-approve in CI/CD?
A: For non-production environments, yes. For production, no—always require human approval. Some teams require approval for any destructive change, even in lower environments.
Have a specific CI/CD challenge? Debugging a pipeline failure? Designing a promotion workflow for your team? Share your scenario in the comments below—our community includes practitioners from hundreds of organizations! 💬
Comments
Post a Comment