Back

Infrastructure as Code (IaC) in Practice: Why Terraform

Aug 12, 2025 (9mo ago)

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.out

Terraform 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 plan reveals 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 -a

CI/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.out

Notes:

  • Create the IAM role gha-terraform-dev with trust policy for GitHub OIDC
  • Use separate roles per env (gha-terraform-prod with 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, and tflint
  • 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 destroy

That’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.