iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Infrastructure as Code

Infrastructure as Code uses Terraform / OpenTofu / Pulumi / CDK to describe cloud resources in source. Every change is a PR, every drift is detectable, every environment is reproducible. Pair with state locking, plan/apply review, and an OIDC-authenticated CI runner.

Terraform module + GitHub Actions plan/apply

EXAMPLE
# 1) Project layout
# infra/
# ├── modules/
# │   ├── vpc/          # reusable module: vpc + subnets + nat
# │   └── ecs-service/  # reusable: task def + service + alb target group
# ├── envs/
# │   ├── staging/
# │   │   ├── main.tf
# │   │   ├── variables.tf
# │   │   └── backend.tf
# │   └── prod/
# │       ├── main.tf
# │       └── ...
# └── README.md

# 2) envs/prod/backend.tf — remote state with locking
# terraform {
#   required_version = '~> 1.8'
#   backend 's3' {
#     bucket         = 'shop-tf-state-prod'
#     key            = 'shop/prod/terraform.tfstate'
#     region         = 'ap-southeast-2'
#     dynamodb_table = 'shop-tf-locks'
#     encrypt        = true
#   }
# }

# 3) envs/prod/main.tf
# terraform {
#   required_providers {
#     aws = { source = 'hashicorp/aws', version = '~> 5.0' }
#   }
# }
#
# provider 'aws' { region = 'ap-southeast-2' }
#
# module 'vpc' {
#   source     = '../../modules/vpc'
#   name       = 'shop-prod'
#   cidr_block = '10.20.0.0/16'
#   azs        = ['ap-southeast-2a', 'ap-southeast-2b', 'ap-southeast-2c']
# }
#
# module 'api' {
#   source       = '../../modules/ecs-service'
#   service_name = 'shop-api'
#   image        = var.api_image
#   cpu          = 512
#   memory       = 1024
#   vpc_id       = module.vpc.id
#   subnet_ids   = module.vpc.private_subnets
#   desired_count = 3
# }
#
# variable 'api_image' { type = string }
# output 'alb_dns_name' { value = module.api.alb_dns_name }

# 4) modules/ecs-service/variables.tf
# variable 'service_name' { type = string }
# variable 'image'        { type = string }
# variable 'cpu'          { type = number }
# variable 'memory'       { type = number }
# variable 'vpc_id'       { type = string }
# variable 'subnet_ids'   { type = list(string) }
# variable 'desired_count' { type = number, default = 1 }

# 5) GitHub Actions — plan on PR, apply on merge to main
# .github/workflows/iac.yml
# name: iac
# on:
#   pull_request:
#     paths: [ 'infra/**' ]
#   push:
#     branches: [ main ]
#     paths: [ 'infra/**' ]
#
# permissions:
#   id-token: write
#   contents: read
#   pull-requests: write
#
# jobs:
#   plan:
#     if: github.event_name == 'pull_request'
#     runs-on: ubuntu-latest
#     strategy: { matrix: { env: [staging, prod] } }
#     steps:
#       - uses: actions/checkout@v4
#       - uses: aws-actions/configure-aws-credentials@v4
#         with: { role-to-assume: ${{ secrets.TF_ROLE_ARN }}, aws-region: ap-southeast-2 }
#       - uses: hashicorp/setup-terraform@v3
#       - working-directory: infra/envs/${{ matrix.env }}
#         run: |
#           terraform init -input=false
#           terraform fmt -check
#           terraform validate
#           terraform plan -input=false -no-color -out=plan.bin
#           terraform show -no-color plan.bin > plan.txt
#       - uses: actions/upload-artifact@v4
#         with: { name: plan-${{ matrix.env }}, path: infra/envs/${{ matrix.env }}/plan.txt }
#       - name: Comment plan
#         uses: actions/github-script@v7
#         with: |
#           const fs = require('fs');
#           const body = '### Plan ${{ matrix.env }}
' +
#                        '\`\`\`
' + fs.readFileSync('infra/envs/${{ matrix.env }}/plan.txt', 'utf8') + '
\`\`\`';
#           github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body });
#
#   apply:
#     if: github.event_name == 'push' && github.ref == 'refs/heads/main'
#     runs-on: ubuntu-latest
#     environment: production           # requires reviewer in GitHub UI
#     strategy: { matrix: { env: [staging, prod] } }
#     steps:
#       - uses: actions/checkout@v4
#       - uses: aws-actions/configure-aws-credentials@v4
#         with: { role-to-assume: ${{ secrets.TF_ROLE_ARN }}, aws-region: ap-southeast-2 }
#       - uses: hashicorp/setup-terraform@v3
#       - working-directory: infra/envs/${{ matrix.env }}
#         run: |
#           terraform init -input=false
#           terraform apply -input=false -auto-approve

# 6) Conventions
# - One state file per environment (staging/prod isolation)
# - OIDC to assume the AWS role (no static keys)
# - DynamoDB lock prevents concurrent apply races
# - Branch protection: at least one human review on infra/** PRs
# - Drift detection: 'terraform plan' on a schedule; alert on non-zero diff
# - Sensitive outputs marked sensitive = true; never echoed in logs

# 7) Decision tree
# - Single cloud, simple infra      -> Terraform / OpenTofu
# - Mostly k8s, multi-cloud         -> Pulumi (TypeScript) or Crossplane
# - 'I am the only operator'        -> Pulumi has the best DX
# - AWS-only, prefers JS/TS         -> AWS CDK (CloudFormation under the hood)

Why it matters

OIDC + per-environment state + a required PR review on `infra/**` is the trio that turns "Terraform on a Friday" from a horror story into a boring routine. The reviewer reads the plan, the merge applies in CI, and "rollback" is the same workflow re-applying the previous commit — no laptop deploys, no shared static keys, no surprises.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
# Terraform plan/apply in CI.
steps:
    - run: terraform init
    - run: terraform plan -out=plan.out
    - if: github.ref == 'refs/heads/main'
      run: terraform apply -auto-approve plan.out
Try it Yourself »

Discussion

Loading…