IaC in Practice: Why Terraform
You’ve likely clicked around cloud consoles and written some YAML. This post connects the dots: what Infrastructure as Code really means, why Terraform is a great fit for most teams, and how to structure a production-ready workflow.
What is IaC (and why it matters)
Infrastructure as Code treats your cloud resources as versioned, testable, reviewable artifacts—like application code.
- Repeatability: Same code, same result across dev/stage/prod
- Reviewability: Pull requests for infra; no more “who clicked what?”
- Auditability: Git history is your change log
- Safety: Plans show what will change before it does
- Speed: Bootstrap new envs in minutes, not days
Declarative vs. imperative (the Terraform way)
- Imperative: “Create VPC, then subnets, then route tables…” (you script steps)
- Declarative: “I want a VPC with two public subnets.” (you declare state; the tool figures steps)
Terraform is declarative. You describe the desired end state, Terraform builds a dependency graph and performs the minimal set of changes to converge on that state.
# Example: a tag declared once, applied everywhere
variable "project" { type = string }
locals { common_tags = { Project = var.project } }Why Terraform?
- Multi-cloud and beyond: One workflow for AWS/GCP/Azure, plus SaaS (Cloudflare, Datadog, GitHub, etc.)
- Mature planning engine: Clear, human-readable plan and precise change graph
- Huge provider and module ecosystem: Reuse battle-tested building blocks
- Simple language (HCL): Easier to review than generic-purpose languages (and safer for teams)
- Great for teams: Remote state, drift detection, and CI/CD patterns are well understood
When might you choose something else?
- CloudFormation/Bicep: If you’re 100% in one cloud and want native tooling
- CDK/Pulumi: If you truly need a general-purpose language for dynamic constructs
Core building blocks (mental model)
- Provider: Cloud/SaaS API adapter (AWS, GCP, Azure, Cloudflare, GitHub)
- Resource: Thing you create (VPC, S3, ECS, Cloud SQL)
- Data: Read-only lookup (existing AMI, hosted zone)
- Module: Reusable building block (your opinionated VPC, service, DB)
- State: Source of truth of what exists (store remotely!)
Recommended layout
infra/
modules/
vpc/
main.tf
variables.tf
outputs.tf
ecs_service/
main.tf
variables.tf
outputs.tf
environments/
dev/
backend.tf # remote state per env
versions.tf # provider + Terraform versions
variables.tf
main.tf # compose modules
terraform.tfvars
prod/
...Why this layout?
- Clear separation of reusable modules vs. env composition
- Per-environment state, vars, and drift isolation
- Easy for CI to target
environments/{env}
Example module: VPC
// infra/modules/vpc/variables.tf
variable "name" { type = string }
variable "cidr" { type = string }
variable "az_count" { type = number default = 2 }
variable "tags" { type = map(string) default = {} }// infra/modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = merge({ Name = "${var.name}-vpc" }, var.tags)
}
locals {
azs = slice(data.aws_availability_zones.available.names, 0, var.az_count)
}
data "aws_availability_zones" "available" { state = "available" }
resource "aws_subnet" "public" {
for_each = toset(local.azs)
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr, 8, index(local.azs, each.key))
map_public_ip_on_launch = true
availability_zone = each.key
tags = merge({ Name = "${var.name}-public-${each.key}" }, var.tags)
}// infra/modules/vpc/outputs.tf
output "vpc_id" { value = aws_vpc.this.id }
output "public_subnet_ids" { value = values(aws_subnet.public)[*].id }Compose in an environment
// infra/environments/dev/versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}// infra/environments/dev/backend.tf
terraform {
backend "s3" {
bucket = "your-tfstate-bucket"
key = "envs/dev/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "tf-locks"
encrypt = true
}
}// infra/environments/dev/variables.tf
variable "aws_region" { type = string }
variable "project" { type = string }// infra/environments/dev/main.tf
module "vpc" {
source = "../../modules/vpc"
name = "${var.project}-dev"
cidr = "10.20.0.0/16"
az_count = 2
tags = {
Project = var.project
Env = "dev"
}
}
// Example: pass outputs to other modules (ecs_service, rds, etc.)
// module "service" { ... subnets = module.vpc.public_subnet_ids ... }// infra/environments/dev/terraform.tfvars
aws_region = "ap-south-1"
project = "portfolio"Initialize and apply:
cd infra/environments/dev
terraform init
terraform plan -out=plan.out
terraform apply plan.outTerraform state demystified (and remote state)
- What is state? A file that records the real IDs and attributes of created resources so Terraform can plan deltas. Lose it and Terraform loses context.
- Drift: Manual console changes diverge from code.
terraform planreveals drift; decide to import or revert. - Locking: Prevents two applies at once. On AWS, use S3 backend + DynamoDB table for locks; on GCP use Terraform Cloud or workarounds.
- Remote is mandatory: Never commit state. Use S3 (+ KMS + DynamoDB), GCS, or Terraform Cloud. Separate key per environment.
- Outputs: Use outputs to pass IDs to apps/CI, but avoid exposing secrets.
Security and secrets
- Don’t store secrets in state. Many providers return credentials as attributes that end up in tfstate.
- Prefer a secrets manager (AWS Secrets Manager, SSM Parameter Store, GCP Secret Manager, Vault) and reference via data sources.
- Mark variables/outputs as sensitive to avoid showing in plans and logs:
variable "db_password" {
type = string
sensitive = true
}
# Avoid outputting secrets, but if you must, mark them sensitive
output "db_password" {
value = var.db_password
sensitive = true
}When Terraform isn’t the right tool
- Per-request/app runtime config (feature flags, app settings) → app config service
- Highly dynamic, short-lived resources tightly controlled by app code → consider SDKs/operators
- Platform-native stacks you’ll never leave (e.g., simple Azure-only shop) → Bicep can be fine
- Manual exploratory work → do it, then codify what you keep
Quality gates: format, lint, validate
Use pre-commit hooks so every PR is clean:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.88.0
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflint# .tflint.hcl
plugin "aws" { enabled = true }
ruleset { enabled = true }Run locally once:
pre-commit install
pre-commit run -aCI/CD with GitHub Actions (OIDC to AWS)
- Use OIDC to assume an AWS role (no long-lived secrets)
- Separate jobs for fmt/validate, plan, and apply
name: terraform
on:
pull_request:
paths: ["infra/**"]
push:
branches: ["main"]
paths: ["infra/**"]
permissions:
id-token: write
contents: read
env:
TF_IN_AUTOMATION: true
TF_INPUT: false
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra/environments/dev
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: 1.7.5 }
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-terraform-dev
aws-region: ap-south-1
- run: terraform init -input=false
- run: terraform fmt -check
- run: terraform validate
- run: terraform plan -no-color -out=plan.out
- uses: actions/upload-artifact@v4
with:
name: tfplan-dev
path: infra/environments/dev/plan.out
apply:
if: github.ref == 'refs/heads/main'
needs: plan
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra/environments/dev
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: 1.7.5 }
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-terraform-dev
aws-region: ap-south-1
- run: terraform init -input=false
- uses: actions/download-artifact@v4
with:
name: tfplan-dev
path: .
- run: terraform apply -auto-approve plan.outNotes:
- Create the IAM role
gha-terraform-devwith trust policy for GitHub OIDC - Use separate roles per env (
gha-terraform-prodwith stricter perms)
TL;DR checklist
- Pin Terraform and provider versions in
versions.tf - Use remote state with locking per environment
- Structure:
infra/modules/*+infra/environments/{dev,stage,prod} - Pre-commit:
terraform fmt,validate, andtflint - CI: PR creates a plan; main applies with OIDC to assume cloud roles
- Document: module inputs/outputs, env variables, and RACI for who can apply
Common gotchas
| Problem | Tip |
|---|---|
| Drift from manual changes | Use drift-detection: scheduled terraform plan on main |
| State lock errors | Ensure DynamoDB table exists; avoid killing apply mid-run |
| Breaking provider updates | Pin provider versions in versions.tf |
| Overly chatty plans | Prefer modules with sane defaults and ignore_changes for tags when needed |
| Long apply times | Split stacks (network, data, apps) to parallelize |
Cleanup
# Destroy only the dev env
cd infra/environments/dev
terraform destroyThat’s a lean, production-friendly Terraform workflow you can drop into most projects. Tweak modules to match your stack, wire OIDC, and ship infra with PRs.