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…