Terraform Version Control Integration
Treating infrastructure code with the same discipline as application code — pull requests, code review, automated testing, and deployment pipelines — is the defining characteristic of a mature DevOps practice. Terraform integrates naturally with Git to make this possible.
What to Commit to Git
project/├── ✅ main.tf # Commit — resource definitions├── ✅ variables.tf # Commit — variable declarations├── ✅ outputs.tf # Commit — output definitions├── ✅ versions.tf # Commit — provider and TF version constraints├── ✅ .terraform.lock.hcl # Commit — exact provider versions (crucial!)├── ✅ terraform.tfvars.example # Commit — template showing required vars (no values)├── ❌ terraform.tfvars # DO NOT commit — may contain secrets├── ❌ .terraform/ # DO NOT commit — provider binaries, auto-regenerated├── ❌ terraform.tfstate # DO NOT commit — use remote backend instead├── ❌ terraform.tfstate.backup # DO NOT commit├── ❌ *.auto.tfvars # CAREFUL — may contain secrets└── ❌ crash.log # DO NOT commit — Terraform crash reports.gitignore for Terraform
.terraform/terraform.tfstateterraform.tfstate.backup*.tfstate*.tfstate.*.terraform.tfvarsterraform.tfvarsoverride.tfoverride.tf.json*_override.tf*_override.tf.jsoncrash.logcrash.*.log*.tfplanThe .terraform.lock.hcl file is the exception — always commit it so all team members and CI/CD runners use identical provider versions.
Branching Strategy for Terraform
Feature Branch Workflow (Recommended)
main (protected)├── Always deployable — matches production state├── Direct pushes blocked└── Changes only via pull requests
feature/add-redis-cluster├── New branch for each change├── PR triggers automated plan└── Merged after review + plan approval
hotfix/fix-security-group-rule└── Emergency changes follow same PR processEnvironment Branching (For Multi-Environment)
main → deploys to dev automaticallyrelease/v2.1 → deploys to staging on tagproduction → deploys to production after manual approvalGitHub Actions CI/CD Pipeline
A complete, production-grade Terraform pipeline:
name: Terraform CI/CD
on: push: branches: [main] paths: ['infrastructure/**'] pull_request: branches: [main] paths: ['infrastructure/**']
permissions: id-token: write # OIDC auth to AWS contents: read pull-requests: write
jobs: validate: name: Validate runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.8.0" - name: Init run: terraform init -backend=false working-directory: infrastructure/ - name: Validate run: terraform validate working-directory: infrastructure/ - name: Format check run: terraform fmt -check -recursive working-directory: infrastructure/
plan: name: Plan runs-on: ubuntu-latest needs: validate if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/TerraformPlanRole aws-region: us-east-1 - uses: hashicorp/setup-terraform@v3 - name: Init run: terraform init -input=false working-directory: infrastructure/ - name: Plan id: plan run: terraform plan -input=false -no-color -out=tfplan working-directory: infrastructure/ - name: Upload plan uses: actions/upload-artifact@v4 with: name: tfplan path: infrastructure/tfplan - name: Comment PR uses: actions/github-script@v7 with: script: | const plan = `${{ steps.plan.outputs.stdout }}`; github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: `## Terraform Plan\n\`\`\`hcl\n${plan}\n\`\`\`` });
apply: name: Apply runs-on: ubuntu-latest needs: [validate] if: github.event_name == 'push' && github.ref == 'refs/heads/main' environment: production # Requires manual approval gate steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/TerraformApplyRole aws-region: us-east-1 - uses: hashicorp/setup-terraform@v3 - name: Init run: terraform init -input=false working-directory: infrastructure/ - name: Apply run: terraform apply -input=false -auto-approve working-directory: infrastructure/OIDC Authentication (No Long-Lived AWS Keys)
Instead of storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as GitHub secrets, use OIDC — GitHub Actions assumes an IAM role directly:
# IAM role for GitHub Actions OIDCresource "aws_iam_role" "github_actions_terraform" { name = "github-actions-terraform-plan"
assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Federated = "arn:aws:iam::${data.aws_caller_identity.current.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:my-org/my-repo:*" } } }] })}This eliminates static credentials from CI/CD systems entirely — a major security improvement.
Pre-commit Hooks for Local Quality Gates
repos: - repo: https://github.com/antonbabenko/pre-commit-terraform rev: v1.92.0 hooks: - id: terraform_fmt - id: terraform_validate - id: terraform_docs - id: terraform_tflint - id: terraform_checkov # Security and compliance scanningpip install pre-commitpre-commit install# Now every `git commit` automatically validates and formats Terraform codePull Request Checklist for Terraform Changes
Before merging a Terraform PR, reviewers should verify:
- Plan shows only expected changes (no unintended destroys)
- No secrets or sensitive values in plain text
- New resources have required tags
prevent_destroyset on stateful resources (databases, S3 buckets)- Module versions pinned, not
latest - Provider version constraints not weakened
- Plan has been reviewed in CI (not just local)