Showing posts with label terraform. Show all posts
Showing posts with label terraform. Show all posts

Friday, April 17, 2026

Infrastructure as Code in 2026: Terraform Modules, Terragrunt, State Management, and Testing

Hero image

Introduction

Infrastructure as Code matured from "scripts that provision things" to a disciplined engineering practice with version control, peer review, automated testing, and deployment pipelines. That maturity was hard-won. The ecosystem earned its scars — teams that lost an afternoon to a corrupted state file, engineers who discovered a three-month-old manual console change during an incident, organizations that started with one Terraform monolith and spent six months carving it apart.

By 2026, Terraform is the standard IaC tool for AWS infrastructure, Terragrunt is the standard DRY wrapper around it, and the teams operating at scale have developed clear opinions on state file organization, module design, drift prevention, and testing. The tutorials still show you how to provision an EC2 instance. This post covers what happens after that — when you have five engineers, three environments, and twenty services, and you need infrastructure changes to be as reliable and reviewable as application code changes.

The problems that don't appear in tutorials are the ones that matter: state file contention when two engineers apply simultaneously, module sprawl when every team re-implements the same ECS service pattern, environment drift when prod silently diverges from staging over three months, and untestable Terraform that nobody dares touch because it might break something.

This post takes positions. One state file per workload. Terragrunt over workspace-based multi-env management. Terratest over manual verification. These opinions are grounded in operational experience, not framework loyalty. Where alternatives are genuinely reasonable, you'll see them called out. Where one approach is clearly better, the post says so.

The target is an Advanced engineer comfortable with Terraform fundamentals who needs to scale an IaC practice across a team — not someone learning to write their first resource block.


1. Terraform Module Design

Modules are Terraform's unit of reuse. Done well, they reduce duplication and encode institutional knowledge about how your organization provisions infrastructure. Done poorly, they become wrappers with sixty required inputs and no sensible defaults — worse than no module at all.

Interface Design: Minimal Required Inputs, Sensible Defaults, Escape Hatches

A well-designed module interface follows three principles. Required inputs are the minimum set that cannot have a sensible default: the service name, the container image URI, the environment tag. Optional inputs with defaults cover the 80% case: port 8080, memory 512, CPU 256. Escape hatches let callers override anything the module doesn't parameterize directly, typically via a tags merge or a raw aws_ecs_task_definition override block.

Every input that callers have to specify because you were too lazy to provide a default is friction. Every required input that could be derived from other inputs is a design smell.

Versioned Modules: Private Registry vs Git Tags

Use a private Terraform Registry (Terraform Cloud or a self-hosted registry) when you have a platform team responsible for module maintenance and a consuming team that should not need to understand the underlying implementation. Registry versioning enforces explicit upgrades and gives you a module changelog.

Use Git tags (git::https://github.com/org/terraform-modules.git//modules/ecs-service?ref=v1.4.2) when your org is small, module consumers are also contributors, and you want transparency into what changed without an additional system. Git tag references work identically to registry references in Terraform.

Never use ref=main in production. Pin to a tag. Floating references mean your infrastructure can change on the next terraform init.

Module Composition

Root modules are the entry points — they call child modules and wire outputs between them. Child modules are the reusable units. Provider resources live inside child modules or occasionally directly in root modules when they're environment-specific one-offs.

The dependency graph should be a DAG, not a web. Networking outputs feed the app module. App module outputs feed the database module. Database module outputs feed the monitoring module. Circular dependencies between modules are a signal that your service boundary is wrong.

The Wrapper Module Anti-Pattern

A wrapper module that does nothing but pass inputs through to an upstream module — adding no validation, no defaults, no composition — is technical debt. It adds a layer of indirection without adding value. The one justified exception: a wrapper that enforces your organization's tagging policy or naming convention that the upstream module doesn't enforce. Even then, consider whether a validation block in a shared variables.tf convention achieves the same goal without an extra module layer.

Input Validation with validation Blocks

Fail at plan time, not apply time. validation blocks run during plan and produce clear error messages without making any API calls.

# modules/ecs-service/variables.tf

variable "service_name" {
  type        = string
  description = "Name of the ECS service. Used in resource naming and tagging."

  validation {
    # Enforce kebab-case naming: lowercase letters, digits, hyphens only.
    # Prevents CloudWatch metric dimension mismatches and IAM path errors.
    condition     = can(regex("^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$", var.service_name))
    error_message = "service_name must be 3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphens."
  }
}

variable "container_port" {
  type        = number
  description = "Port the container listens on. ALB target group health check uses this port."
  default     = 8080

  validation {
    condition     = var.container_port >= 1024 && var.container_port <= 65535
    error_message = "container_port must be a non-privileged port (1024-65535)."
  }
}

variable "desired_count" {
  type        = number
  description = "Desired number of ECS tasks. Production should be >= 2 for HA."
  default     = 2

  validation {
    condition     = var.desired_count >= 1 && var.desired_count <= 100
    error_message = "desired_count must be between 1 and 100."
  }
}

variable "cpu" {
  type        = number
  description = "CPU units for the ECS task (256, 512, 1024, 2048, 4096). See Fargate task size table."
  default     = 256

  validation {
    # Fargate only allows specific CPU values. Catching this at plan time avoids
    # a confusing AWS API error during apply.
    condition     = contains([256, 512, 1024, 2048, 4096], var.cpu)
    error_message = "cpu must be one of: 256, 512, 1024, 2048, 4096 (Fargate task CPU values)."
  }
}

variable "memory" {
  type        = number
  description = "Memory (MB) for the ECS task. Must match valid Fargate cpu/memory combinations."
  default     = 512
}

variable "container_image" {
  type        = string
  description = "Docker image URI including tag or digest. Use digest for deterministic deployments."

  validation {
    # Require a tag or digest — bare image names without tags default to :latest,
    # which makes deployments non-deterministic.
    condition     = can(regex(":.+$", var.container_image))
    error_message = "container_image must include a tag or digest (e.g. myrepo/myimage:v1.2.3 or myrepo/myimage@sha256:...)."
  }
}

variable "environment_variables" {
  type        = map(string)
  description = "Non-secret environment variables. Secrets should use secrets_arns instead."
  default     = {}
}

variable "secrets_arns" {
  type        = map(string)
  description = "Map of env var name to Secrets Manager ARN. Injected as ECS secrets (not plain env vars)."
  default     = {}
}

variable "extra_security_group_ids" {
  type        = list(string)
  description = "Additional security group IDs to attach to the ECS service ENI. Escape hatch for VPC endpoint access."
  default     = []
}

variable "tags" {
  type        = map(string)
  description = "Tags merged onto all resources. Common tags (team, env) should come from the root module."
  default     = {}
}
# modules/ecs-service/main.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0, < 6.0"
    }
  }
}

locals {
  # Merge caller-provided tags with module-generated tags.
  # Module-generated tags are the minimum required for cost allocation and incident response.
  base_tags = {
    ManagedBy = "terraform"
    Module    = "ecs-service"
  }
  merged_tags = merge(local.base_tags, var.tags)
}

resource "aws_cloudwatch_log_group" "service" {
  # One log group per service. Retention prevents unbounded CloudWatch costs.
  name              = "/ecs/${var.service_name}"
  retention_in_days = 30
  tags              = local.merged_tags
}

resource "aws_ecs_task_definition" "service" {
  family                   = var.service_name
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = var.cpu
  memory                   = var.memory
  execution_role_arn       = aws_iam_role.execution.arn
  task_role_arn            = aws_iam_role.task.arn

  container_definitions = jsonencode([
    {
      name      = var.service_name
      image     = var.container_image
      essential = true

      portMappings = [
        {
          containerPort = var.container_port
          protocol      = "tcp"
        }
      ]

      # Separate environment (plain text) from secrets (Secrets Manager injection).
      # This distinction matters for audit logs and prevents accidental secret exposure in task definitions.
      environment = [
        for k, v in var.environment_variables : { name = k, value = v }
      ]

      secrets = [
        for k, arn in var.secrets_arns : { name = k, valueFrom = arn }
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.service.name
          "awslogs-region"        = data.aws_region.current.name
          "awslogs-stream-prefix" = "ecs"
        }
      }
    }
  ])

  tags = local.merged_tags
}

resource "aws_ecs_service" "service" {
  name            = var.service_name
  cluster         = var.ecs_cluster_id
  task_definition = aws_ecs_task_definition.service.arn
  desired_count   = var.desired_count

  launch_type = "FARGATE"

  network_configuration {
    subnets = var.private_subnet_ids
    security_groups = concat(
      [aws_security_group.service.id],
      var.extra_security_group_ids  # escape hatch for VPC endpoint SGs
    )
    assign_public_ip = false
  }

  # Ignore desired_count changes in Terraform state — auto-scaling manages this at runtime.
  # Without this, every terraform apply resets the count to the Terraform value,
  # undoing auto-scaling decisions.
  lifecycle {
    ignore_changes = [desired_count]
  }

  tags = local.merged_tags
}
# modules/ecs-service/outputs.tf

# Output everything a downstream module might need.
# It's cheap to output; it's expensive to add outputs later when a consumer needs them.

output "service_name" {
  value       = aws_ecs_service.service.name
  description = "ECS service name. Used by deployment scripts and monitoring dashboards."
}

output "service_arn" {
  value       = aws_ecs_service.service.id
  description = "ECS service ARN. Required for CodeDeploy deployment group configuration."
}

output "task_role_arn" {
  value       = aws_iam_role.task.arn
  description = "IAM role ARN for the ECS task. Attach additional policies here for S3/DynamoDB access."
}

output "security_group_id" {
  value       = aws_security_group.service.id
  description = "Security group ID for the ECS service ENI. Reference from RDS or ElastiCache ingress rules."
}

output "log_group_name" {
  value       = aws_cloudwatch_log_group.service.name
  description = "CloudWatch log group name. Used in CloudWatch Insights queries and alarms."
}
flowchart TD ROOT["Root Module\n(env/prod/main.tf)"] --> NET["networking module\noutputs: vpc_id, subnet_ids, sg_ids"] ROOT --> APP["app module (ecs-service)\ninputs: vpc_id, subnet_ids from networking\noutputs: service_sg_id, task_role_arn"] ROOT --> DB["database module (rds)\ninputs: service_sg_id from app\noutputs: db_endpoint, db_secret_arn"] ROOT --> MON["monitoring module\ninputs: log_group_name from app\n db_endpoint from database"] NET -->|vpc_id, private_subnet_ids| APP APP -->|security_group_id| DB APP -->|log_group_name| MON DB -->|db_secret_arn| APP

2. Terragrunt for DRY Multi-Environment Configurations

The Terraform multi-environment problem is well-documented and poorly solved by workspaces. Workspaces share a backend, share a state file, and require workspace-specific variable files that Terraform has no native mechanism to inherit. The result is either duplication — three copies of identical main.tf files — or fragile variable injection through TF_VAR_ environment variables in CI.

Terragrunt is an HCL wrapper around Terraform that solves this with a simple inheritance model: environment-specific configuration inherits from a shared _envcommon directory, overriding only what differs.

Directory Structure

infrastructure/
├── _envcommon/                    # Shared config inherited by all environments
│   ├── ecs-service.hcl            # Shared ECS service inputs
│   └── rds.hcl                    # Shared RDS inputs
├── terragrunt.hcl                 # Root config: remote state, provider generation
├── dev/
│   ├── env.hcl                    # Environment-specific vars (env = "dev", region = "us-east-1")
│   ├── ecs-service/
│   │   └── terragrunt.hcl         # Inherits _envcommon/ecs-service.hcl, overrides desired_count
│   └── rds/
│       └── terragrunt.hcl
├── staging/
│   ├── env.hcl
│   ├── ecs-service/
│   │   └── terragrunt.hcl
│   └── rds/
│       └── terragrunt.hcl
└── prod/
    ├── env.hcl
    ├── ecs-service/
    │   └── terragrunt.hcl         # Overrides: desired_count = 4, cpu = 1024
    └── rds/
        └── terragrunt.hcl         # Overrides: instance_class = "db.r6g.large"

Root terragrunt.hcl — Remote State and Provider Generation

# infrastructure/terragrunt.hcl
# Root config inherited by every child terragrunt.hcl via find_in_parent_folders()

locals {
  # Parse the environment from the directory path.
  # infrastructure/prod/ecs-service → env = "prod"
  path_components = split("/", path_relative_to_include())
  env             = local.path_components[0]

  # Load environment-specific variables from env.hcl
  env_vars   = read_terragrunt_config(find_in_parent_folders("env.hcl"))
  aws_region = local.env_vars.locals.aws_region
  account_id = local.env_vars.locals.account_id
}

# Generate provider.tf in each module directory at plan/apply time.
# This avoids repeating the provider block in every module and ensures
# the assume_role ARN is always environment-specific.
generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "aws" {
  region = "${local.aws_region}"

  assume_role {
    # Each environment deploys into a separate AWS account.
    # This prevents a prod-targeted apply from hitting dev resources.
    role_arn = "arn:aws:iam::${local.account_id}:role/TerraformDeployRole"
  }

  default_tags {
    tags = {
      Environment = "${local.env}"
      ManagedBy   = "terragrunt"
    }
  }
}
EOF
}

# Remote state configuration.
# State files are isolated per module: s3://bucket/env/module-name/terraform.tfstate
remote_state {
  backend = "s3"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
  config = {
    bucket         = "myorg-terraform-state-${local.account_id}"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = local.aws_region
    encrypt        = true
    dynamodb_table = "terraform-state-lock"

    # S3 bucket versioning must be enabled separately (see state management section).
    # Versioning allows state rollback after a botched apply.
  }
}

_envcommon/ecs-service.hcl — Shared Defaults

# infrastructure/_envcommon/ecs-service.hcl
# Inputs that are identical across dev/staging/prod.
# Environment-specific overrides happen in each env's terragrunt.hcl.

locals {
  env_vars  = read_terragrunt_config(find_in_parent_folders("env.hcl"))
  env       = local.env_vars.locals.env
}

inputs = {
  service_name   = "api-service"
  container_port = 8080
  cpu            = 256    # Override in prod to 1024
  memory         = 512    # Override in prod to 2048
  desired_count  = 1      # Override in prod to 4
}

prod/ecs-service/terragrunt.hcl — Environment Override

# infrastructure/prod/ecs-service/terragrunt.hcl

include "root" {
  path = find_in_parent_folders()
}

include "envcommon" {
  # Pull in shared defaults. merge strategy means prod inputs override envcommon inputs.
  path   = "${dirname(find_in_parent_folders())}/_envcommon/ecs-service.hcl"
  expose = true
  merge_strategy = "deep"
}

# dependency block wires cross-module outputs without hardcoding ARNs.
# Terragrunt runs a targeted plan/output on the dependency before applying this module.
dependency "networking" {
  config_path = "../networking"

  # mock_outputs are used during `plan` when the dependency hasn't been applied yet.
  # This enables plan-on-PR without requiring a live networking stack.
  mock_outputs = {
    vpc_id             = "vpc-00000000"
    private_subnet_ids = ["subnet-00000000", "subnet-11111111"]
  }
  mock_outputs_allowed_terraform_commands = ["plan", "validate"]
}

dependency "rds" {
  config_path  = "../rds"
  mock_outputs = {
    db_secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:mock-db-secret"
  }
  mock_outputs_allowed_terraform_commands = ["plan", "validate"]
}

terraform {
  source = "git::https://github.com/myorg/terraform-modules.git//modules/ecs-service?ref=v2.1.0"
}

# Deep merge with envcommon — only override what differs in prod.
inputs = merge(
  include.envcommon.inputs,
  {
    # Production capacity — override shared defaults
    cpu           = 1024
    memory        = 2048
    desired_count = 4

    # Wire in dependency outputs — no hardcoded ARNs
    vpc_id             = dependency.networking.outputs.vpc_id
    private_subnet_ids = dependency.networking.outputs.private_subnet_ids
    ecs_cluster_id     = dependency.networking.outputs.ecs_cluster_id

    secrets_arns = {
      DATABASE_URL = dependency.rds.outputs.db_secret_arn
    }

    tags = {
      Team        = "platform"
      CostCenter  = "engineering"
    }
  }
)
flowchart TD ROOT["infrastructure/terragrunt.hcl\nRemote state config\nProvider generation\nAccount ID, region locals"] ENVCOMMON["_envcommon/ecs-service.hcl\ncpu=256, memory=512\ndesired_count=1\ncontainer_port=8080"] ENVHCL["prod/env.hcl\nenv=prod\naws_region=us-east-1\naccount_id=111122223333"] ROOT -->|"find_in_parent_folders()"| DEV["dev/ecs-service/terragrunt.hcl\ninherits envcommon\nno overrides"] ROOT -->|"find_in_parent_folders()"| STG["staging/ecs-service/terragrunt.hcl\ninherits envcommon\ndesired_count=2"] ROOT -->|"find_in_parent_folders()"| PROD["prod/ecs-service/terragrunt.hcl\nmerge(envcommon.inputs, {...})\ncpu=1024, desired_count=4"] ENVCOMMON -->|"include envcommon"| DEV ENVCOMMON -->|"include envcommon"| STG ENVCOMMON -->|"include envcommon"| PROD ENVHCL -->|"read_terragrunt_config"| ROOT

3. State Management at Scale

State files are the source of truth for what Terraform believes exists in the world. Treating them carelessly — one file for everything, no encryption, no locking — is the fastest path to a disaster that takes hours to recover from.

The Fundamental Rule: One State File Per Workload

Not one per environment, not one per region, not one monolith. One per workload — the unit of infrastructure that gets deployed, scaled, and destroyed together.

"Workload" is a judgment call, but a useful heuristic: if two resources are never applied in the same operation, they belong in different state files. Networking (VPCs, subnets, route tables) is deployed once and rarely changed. Application infrastructure (ECS services, RDS instances) changes frequently. Monitoring and alerting changes on its own cadence. Keep them separate.

A monolithic state file has two failure modes. The first is blast radius: a bug in one resource's configuration can corrupt the entire state. The second is velocity: every change requires a full plan across all resources, even unrelated ones, which is slow and increases the chance of accidental drift.

S3 Backend Configuration

# This is a partial backend configuration.
# The bucket name and region are injected at `terraform init` time via -backend-config flags
# or the Terragrunt remote_state block — never hardcoded in version control.
# Avoids exposing account-specific details in the public module source.

terraform {
  backend "s3" {
    # bucket and key are injected by Terragrunt's remote_state block.
    # Do not specify them here if using Terragrunt.

    region = "us-east-1"

    # Encrypt state at rest. State files contain plaintext secrets (database passwords,
    # API keys) because Terraform stores all resource attributes — including sensitive ones.
    encrypt = true

    # KMS key for state encryption. Default SSE-S3 is acceptable; KMS gives you
    # rotation, audit logs, and cross-account access control.
    kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/mrk-abc123"

    # DynamoDB table for state locking.
    # Lock prevents concurrent applies from corrupting state.
    dynamodb_table = "terraform-state-lock"
  }
}
# S3 bucket for state storage — bootstrapped manually or via a separate "bootstrap" module.
# This is the one piece of infrastructure that cannot manage itself.

resource "aws_s3_bucket" "terraform_state" {
  bucket = "myorg-terraform-state-${data.aws_caller_identity.current.account_id}"

  # Prevent accidental deletion of the state bucket.
  lifecycle {
    prevent_destroy = true
  }

  tags = {
    Purpose   = "terraform-state"
    ManagedBy = "bootstrap"
  }
}

resource "aws_s3_bucket_versioning" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id
  versioning_configuration {
    status = "Enabled"
  }
  # Versioning is the rollback mechanism for state files.
  # After a botched apply, you can restore the previous state version from S3
  # and run terraform apply again to converge.
}

resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.terraform_state.arn
    }
  }
}

resource "aws_s3_bucket_public_access_block" "terraform_state" {
  bucket                  = aws_s3_bucket.terraform_state.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_dynamodb_table" "terraform_lock" {
  name         = "terraform-state-lock"
  billing_mode = "PAY_PER_REQUEST"  # On-demand billing; lock table traffic is spiky
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }

  tags = {
    Purpose   = "terraform-state-lock"
    ManagedBy = "bootstrap"
  }
}

State File Refactoring and Drift Recovery

terraform state mv moves resources between state files without destroying and recreating them. Use it when splitting a monolith or renaming a resource within a module refactor. Always take a state backup before any state mv operation.

terraform import brings existing resources under Terraform management. Use it when a resource was created manually and you want IaC ownership going forward.

Broken DynamoDB locks (from a killed apply process) show up as "Error acquiring the state lock." Verify the lock is actually stale by checking the LockID in DynamoDB and comparing the timestamp. If it's more than a few hours old and no apply is running, use terraform force-unlock <LOCK_ID>. Never force-unlock a live apply.

flowchart LR subgraph BAD["Monolithic State — High Risk"] M["terraform.tfstate\n(single file)\nVPC + ECS + RDS + IAM\n+ CloudWatch + Route53"] style BAD fill:#ffeaea,stroke:#cc0000 end subgraph OK["Per-Environment State — Better"] D["dev/terraform.tfstate\nAll dev resources"] S["staging/terraform.tfstate\nAll staging resources"] P["prod/terraform.tfstate\nAll prod resources"] style OK fill:#fff8e1,stroke:#f9a825 end subgraph GOOD["Per-Workload State — Recommended"] N1["prod/networking\n.tfstate"] A1["prod/ecs-service\n.tfstate"] R1["prod/rds\n.tfstate"] M1["prod/monitoring\n.tfstate"] style GOOD fill:#e8f5e9,stroke:#388e3c end BAD -->|"Any change plans entire infra\nOne corruption = everything broken"| OK OK -->|"Still couples networking+app+db\nFull-env plan for single service change"| GOOD

4. Drift Detection and Remediation

Drift is the delta between what Terraform's state believes exists and what actually exists in AWS. It accumulates through three vectors: manual console changes by engineers under pressure, auto-scaling modifying desired counts, and external automation (Lambda functions, AWS Config remediations, third-party tools) creating or modifying resources.

Undetected drift is the most dangerous state your infrastructure can be in. You think you have IaC. You don't. You have IaC plus a shadow layer of undocumented manual changes that will survive until the next terraform destroy or a major refactor wipes them out.

Drift Detection in CI

Run terraform plan on a schedule — not just on pull requests. A plan that runs only when engineers make changes will never catch drift from external sources.

# .github/workflows/drift-detection.yml

name: Drift Detection

on:
  # Run daily at 6 AM UTC — before the engineering day starts, so drift
  # is visible in Slack before anyone starts making infrastructure changes.
  schedule:
    - cron: "0 6 * * 1-5"
  # Also allow manual trigger for on-demand drift checks.
  workflow_dispatch:

jobs:
  detect-drift:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        # Run drift detection for all environments in parallel.
        environment: [dev, staging, prod]
        module: [networking, ecs-service, rds, monitoring]
    permissions:
      id-token: write  # Required for OIDC authentication to AWS
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ vars[format('{0}_ACCOUNT_ID', matrix.environment)] }}:role/GitHubActionsRole
          aws-region: us-east-1

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.9.0"

      - name: Setup Terragrunt
        run: |
          wget -qO terragrunt "https://github.com/gruntwork-io/terragrunt/releases/download/v0.67.0/terragrunt_linux_amd64"
          chmod +x terragrunt
          sudo mv terragrunt /usr/local/bin/

      - name: Terragrunt Plan (Drift Detection)
        id: plan
        working-directory: infrastructure/${{ matrix.environment }}/${{ matrix.module }}
        run: |
          # -detailed-exitcode: exit 0 = no changes, exit 1 = error, exit 2 = changes detected
          terragrunt plan -detailed-exitcode -out=plan.tfplan 2>&1 | tee plan_output.txt
          echo "exitcode=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
        continue-on-error: true

      - name: Alert on Drift
        if: steps.plan.outputs.exitcode == '2'
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": ":rotating_light: *Terraform Drift Detected*",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": ":rotating_light: *Terraform Drift Detected*\n*Environment:* ${{ matrix.environment }}\n*Module:* ${{ matrix.module }}\n*Workflow:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DRIFT_WEBHOOK_URL }}
          SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

      - name: Fail on Error (not on drift)
        # Exit code 2 means drift, which we alert on but don't fail the workflow.
        # Exit code 1 means a real error (auth failure, provider issue), which should fail.
        if: steps.plan.outputs.exitcode == '1'
        run: exit 1

Handling ignore_changes for Intentional Drift

Some drift is intentional. Auto-scaling modifies desired_count at runtime. Terraform should not reset it on every apply. Use ignore_changes for this, but document why.

resource "aws_ecs_service" "service" {
  # ... other config ...

  lifecycle {
    # desired_count is managed by Application Auto Scaling at runtime.
    # Without this ignore, terraform apply would reset the count to the Terraform value,
    # overriding auto-scaling decisions. This is intentional drift we accept.
    ignore_changes = [desired_count]
  }
}

Preventing Drift: Break-Glass Procedures

The goal is not zero manual console access — emergencies happen. The goal is zero undocumented manual console access. Implement a break-glass procedure: an IAM role that grants console write access, requires MFA, logs all API calls via CloudTrail, and triggers a PagerDuty alert when assumed. After every break-glass event, the engineer responsible must open a Terraform PR capturing the manual change before the end of the sprint.


5. Testing Infrastructure Code

"We can't test infrastructure" is a belief, not a fact. Terraform can be tested at multiple levels — unit, contract, integration, policy, and security — and each level catches different classes of bugs.

Terratest: Real Resources, Real Assertions

Terratest runs actual Terraform, provisions real AWS resources in a test account, runs assertions against them, then destroys everything. It's slow (5-10 minutes per test), it costs money (fractions of a cent per test run), and it catches things static analysis never will.

// modules/ecs-service/test/ecs_service_test.go

package test

import (
    "fmt"
    "testing"
    "time"

    "github.com/gruntwork-io/terratest/modules/aws"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestECSServiceModule(t *testing.T) {
    t.Parallel()

    // Use a unique suffix to avoid conflicts when tests run concurrently.
    uniqueID := fmt.Sprintf("test-%d", time.Now().UnixMilli()%10000)
    serviceName := fmt.Sprintf("test-svc-%s", uniqueID)
    awsRegion := "us-east-1"

    terraformOptions := &terraform.Options{
        // The examples/ directory contains a minimal, self-contained instantiation
        // of the module for testing. It provisions its own VPC and ECS cluster.
        TerraformDir: "../examples/basic",

        Vars: map[string]interface{}{
            "service_name":     serviceName,
            "container_image":  "nginx:1.25.3",  // pinned tag — no :latest in tests
            "container_port":   8080,
            "desired_count":    1,
            "aws_region":       awsRegion,
        },

        // Retry on transient AWS API errors. ECS service creation can take 30-60 seconds.
        RetryableTerraformErrors: map[string]string{
            "Error creating ECS Service":   "ECS service creation is eventually consistent",
            "ResourceInUseException":       "Resource not yet available",
        },
        MaxRetries:         3,
        TimeBetweenRetries: 15 * time.Second,
    }

    // Always destroy resources after test — even if the test fails.
    defer terraform.Destroy(t, terraformOptions)

    terraform.InitAndApply(t, terraformOptions)

    // --- Assertions ---

    serviceArn := terraform.Output(t, terraformOptions, "service_arn")
    require.NotEmpty(t, serviceArn, "service_arn output must not be empty")

    // Verify the ECS service exists and is in a RUNNING state.
    ecsClient := aws.NewEcsClient(t, awsRegion)
    clusterArn := terraform.Output(t, terraformOptions, "cluster_arn")

    service := aws.GetEcsService(t, awsRegion, clusterArn, serviceName)
    assert.Equal(t, "ACTIVE", aws.GetString(service.Status), "ECS service should be ACTIVE")
    assert.Equal(t, int64(1), aws.GetInt64(service.DesiredCount), "desired count should match input")

    // Verify the CloudWatch log group was created.
    logGroupName := terraform.Output(t, terraformOptions, "log_group_name")
    assert.Equal(t, fmt.Sprintf("/ecs/%s", serviceName), logGroupName)

    // Verify the task role ARN follows expected naming convention.
    taskRoleArn := terraform.Output(t, terraformOptions, "task_role_arn")
    assert.Contains(t, taskRoleArn, serviceName, "task role ARN should contain service name")

    // Verify the security group was created and has no ingress from 0.0.0.0/0.
    sgID := terraform.Output(t, terraformOptions, "security_group_id")
    sg := aws.GetSecurityGroup(t, awsRegion, sgID)
    for _, perm := range sg.IpPermissions {
        for _, ipRange := range perm.IpRanges {
            assert.NotEqual(t, "0.0.0.0/0", aws.GetString(ipRange.CidrIp),
                "ECS service security group must not allow ingress from 0.0.0.0/0")
        }
    }

    _ = ecsClient // suppress unused import
}

Checkov: Static Security Analysis

Checkov runs without any AWS credentials — it analyzes Terraform plan output or raw HCL for security misconfigurations. Add it to the PR pipeline, before apply.

# In your GitHub Actions plan workflow, after terraform plan:

- name: Run Checkov
  uses: bridgecrewio/checkov-action@v12
  with:
    directory: .
    framework: terraform
    # Fail the build on HIGH and CRITICAL findings.
    # MEDIUM findings are reported but don't block merge — review weekly.
    soft_fail_on: MEDIUM,LOW,INFO
    output_format: github_failed_only
    # Skip checks that don't apply to your environment.
    # Document why each skip is justified.
    skip_check: >
      CKV_AWS_116,
      CKV_AWS_338

Infracost: Cost Estimation in CI

# .github/workflows/infracost.yml
# Runs on every PR that modifies Terraform files.
# Posts a cost diff comment showing the monthly cost change.

- name: Setup Infracost
  uses: infracost/actions/setup@v3
  with:
    api-key: ${{ secrets.INFRACOST_API_KEY }}

- name: Generate Infracost diff
  run: |
    # Generate cost estimate for the proposed changes
    infracost diff \
      --path=. \
      --format=json \
      --compare-to=infracost-base.json \
      --out-file=infracost-diff.json

- name: Post Infracost comment
  uses: infracost/actions/comment@v3
  with:
    path: infracost-diff.json
    # Show the monthly cost diff in the PR comment.
    # Engineers reviewing the PR can see "this change adds $47/month" before approving.
    behavior: update

6. CI/CD for Infrastructure

Infrastructure CI/CD has different requirements than application CI/CD. The blast radius of a bad deploy is higher. Rollback is harder. The feedback loop from plan to verify is slower. The pipeline design has to account for all three.

Plan on PR: Show Everything Before Merge

# .github/workflows/terraform-pr.yml

name: Terraform Plan

on:
  pull_request:
    paths:
      - "infrastructure/**"
      - "modules/**"

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write

    strategy:
      matrix:
        environment: [dev, staging, prod]

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars[format('{0}_PLAN_ROLE', matrix.environment)] }}
          aws-region: us-east-1

      - name: Setup Terraform and Terragrunt
        run: |
          wget -qO- https://releases.hashicorp.com/terraform/1.9.0/terraform_1.9.0_linux_amd64.zip | unzip -
          sudo mv terraform /usr/local/bin/
          wget -qO terragrunt https://github.com/gruntwork-io/terragrunt/releases/download/v0.67.0/terragrunt_linux_amd64
          chmod +x terragrunt && sudo mv terragrunt /usr/local/bin/

      - name: Terragrunt Plan
        id: plan
        working-directory: infrastructure/${{ matrix.environment }}
        run: |
          terragrunt run-all plan \
            --terragrunt-non-interactive \
            -out=tfplan 2>&1 | tee plan_output.txt

      - name: Run Checkov Policy Check
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: infrastructure/${{ matrix.environment }}
          framework: terraform
          soft_fail_on: MEDIUM,LOW,INFO

      - name: Infracost Cost Diff
        if: matrix.environment == 'prod'  # Cost estimate only matters for prod changes
        run: |
          infracost diff \
            --path=infrastructure/prod \
            --format=json \
            --out-file=infracost.json

      - name: Post Plan to PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const planOutput = fs.readFileSync('infrastructure/${{ matrix.environment }}/plan_output.txt', 'utf8');
            const body = `## Terraform Plan — \`${{ matrix.environment }}\`\n\`\`\`\n${planOutput.slice(-30000)}\n\`\`\``;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body
            });

Apply on Merge: Automated Dev, Gated Prod

# .github/workflows/terraform-apply.yml

name: Terraform Apply

on:
  push:
    branches: [main]
    paths:
      - "infrastructure/**"

jobs:
  apply-dev:
    runs-on: ubuntu-latest
    environment: dev  # No approval required for dev
    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.DEV_APPLY_ROLE }}
          aws-region: us-east-1
      - name: Apply to Dev
        working-directory: infrastructure/dev
        run: |
          terragrunt run-all apply \
            --terragrunt-non-interactive \
            # Apply one module at a time — parallelism=1 limits blast radius.
            # Parallel applies can cause race conditions in resource dependencies.
            -parallelism=1

  apply-staging:
    needs: apply-dev  # Staging applies only after dev succeeds
    runs-on: ubuntu-latest
    environment: staging  # Requires approval from staging-approvers team
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.STAGING_APPLY_ROLE }}
          aws-region: us-east-1
      - name: Apply to Staging
        working-directory: infrastructure/staging
        run: terragrunt run-all apply --terragrunt-non-interactive -parallelism=1

  apply-prod:
    needs: apply-staging  # Prod applies only after staging succeeds
    runs-on: ubuntu-latest
    environment: production  # Requires approval from senior-engineers team — configured in GitHub Environments
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.PROD_APPLY_ROLE }}
          aws-region: us-east-1
      - name: Apply to Prod
        working-directory: infrastructure/prod
        run: terragrunt run-all apply --terragrunt-non-interactive -parallelism=1

Rollback Strategy

Terraform has no native rollback. The rollback mechanism is: retrieve the previous state file version from S3 (S3 versioning must be enabled), restore it locally, and re-apply. This requires that the underlying infrastructure hasn't been destroyed, which is why prevent_destroy = true on critical resources matters.

For destructive changes (renaming a resource, changing a unique constraint), the safer path is usually to apply the new resource alongside the old one, migrate traffic, then destroy the old one — rather than trying to rollback.

Atlantis vs Terraform Cloud vs custom GitHub Actions: Atlantis is the right choice when you want plan/apply workflows in your existing GitHub PR without a SaaS dependency, and you're willing to operate the server. Terraform Cloud is the right choice when you want audit logs, Sentinel policies, and a managed execution environment. Custom GitHub Actions (like the examples above) are the right choice when your team already understands GitHub Actions and the additional systems overhead isn't justified.


Conclusion

Infrastructure as Code at scale is not harder than application engineering — it requires the same disciplines applied to a different problem domain. Module design with explicit interfaces and validation blocks makes configurations reviewable. Terragrunt's inheritance model makes multi-environment management maintainable without copying files. Per-workload state isolation limits blast radius. Scheduled drift detection makes the gap between declared and actual state visible before it becomes an incident. Terratest and Checkov bring the same test-before-merge hygiene to infrastructure that unit tests bring to application code.

The teams that get IaC right treat it exactly like application code: PRs, reviews, tests, CI/CD, and a culture of fixing broken state the same day it's detected rather than letting it accumulate. The teams that don't are the ones debugging 2 AM manual console changes with no audit trail, a corrupted state file, and no clear picture of what was actually running before the incident.

Start with the module design principles and state isolation strategy — those compound over time. Add Terragrunt when copy-paste between environment directories starts causing divergence. Add testing when you have enough modules to justify the investment. Add drift detection the day you catch someone making a console change without a follow-up Terraform PR.

The patterns in this post are not theoretical. They're the patterns that survive contact with production.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-20 · Updated: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Thursday, April 16, 2026

Terraform Advanced Patterns: Modules, Remote State, and Managing Infrastructure Drift

Hero: Terraform state dependency graph with modules and resources

Most teams start Terraform with a single main.tf file and a handful of resources. Six months later, that file is 2,000 lines. Three environments (dev, staging, prod) have separate copies that have gradually diverged. The state file lives in someone's laptop. Applying requires knowing the "correct order" of operations. This is infrastructure as code in name only.

Terraform's advanced patterns — modular architecture, remote state, workspaces, drift detection, and policy-as-code — transform it from a "run once and pray" tool to a reliable, auditable infrastructure platform. This guide covers all of them with production-grade examples.

The Problem: Flat Terraform That Doesn't Scale

The anti-patterns that compound over time:

project/
├── main.tf          # 2,000+ lines of everything
├── variables.tf     # 50 variables, minimal documentation
├── outputs.tf       # What outputs exist? Nobody knows.
└── terraform.tfstate  # State on someone's laptop — NOT in git

When another team member runs terraform apply, they don't have the state file. When you add a new environment, you duplicate the entire directory. When you want to share the VPC configuration with another project, you copy-paste it.

The structured alternative:

infrastructure/
├── modules/
│   ├── vpc/              # Reusable VPC module
│   ├── eks/              # Reusable EKS cluster module
│   ├── rds/              # Reusable RDS module
│   └── service/          # Reusable service-level module
├── environments/
│   ├── dev/              # Dev environment composition
│   ├── staging/          # Staging environment composition
│   └── prod/             # Prod environment composition
└── global/               # Cross-environment resources (DNS, IAM)

How It Works: Module Architecture

A Terraform module is any directory with .tf files. Modules encapsulate resources, expose a clean interface (variables/outputs), and hide implementation details. The calling configuration provides inputs; the module returns outputs.

Writing a Reusable Module

# modules/rds/variables.tf
variable "identifier" {
  description = "Unique identifier for this RDS instance"
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,60}$", var.identifier))
    error_message = "Identifier must be 2-62 lowercase alphanumeric characters or hyphens."
  }
}

variable "engine_version" {
  description = "PostgreSQL engine version"
  type        = string
  default     = "16.2"
}

variable "instance_class" {
  description = "RDS instance type"
  type        = string
  default     = "db.t3.medium"
}

variable "storage_gb" {
  description = "Allocated storage in gigabytes"
  type        = number
  default     = 20

  validation {
    condition     = var.storage_gb >= 20 && var.storage_gb <= 65536
    error_message = "Storage must be between 20 and 65536 GB."
  }
}

variable "subnet_ids" {
  description = "List of subnet IDs for the DB subnet group"
  type        = list(string)
}

variable "vpc_id" {
  description = "VPC ID for security group placement"
  type        = string
}

variable "allowed_security_group_ids" {
  description = "Security group IDs allowed to connect to this RDS instance"
  type        = list(string)
  default     = []
}

variable "tags" {
  description = "Additional tags to apply to resources"
  type        = map(string)
  default     = {}
}
# modules/rds/main.tf
locals {
  common_tags = merge(var.tags, {
    Module  = "rds"
    Managed = "terraform"
  })
}

resource "aws_db_subnet_group" "this" {
  name       = "${var.identifier}-subnet-group"
  subnet_ids = var.subnet_ids
  tags       = local.common_tags
}

resource "aws_security_group" "rds" {
  name        = "${var.identifier}-rds-sg"
  vpc_id      = var.vpc_id
  description = "Security group for ${var.identifier} RDS instance"
  tags        = local.common_tags

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = var.allowed_security_group_ids
    description     = "PostgreSQL from allowed security groups"
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "random_password" "master" {
  length           = 32
  special          = true
  override_special = "!#$%^&*()-_=+[]{}|"
}

resource "aws_secretsmanager_secret" "rds_password" {
  name                    = "/${var.identifier}/rds/master-password"
  recovery_window_in_days = 7
  tags                    = local.common_tags
}

resource "aws_secretsmanager_secret_version" "rds_password" {
  secret_id     = aws_secretsmanager_secret.rds_password.id
  secret_string = random_password.master.result
}

resource "aws_db_instance" "this" {
  identifier     = var.identifier
  engine         = "postgres"
  engine_version = var.engine_version
  instance_class = var.instance_class

  allocated_storage     = var.storage_gb
  max_allocated_storage = var.storage_gb * 2  # Auto-scaling up to 2×
  storage_encrypted     = true                 # Always encrypt

  db_subnet_group_name   = aws_db_subnet_group.this.name
  vpc_security_group_ids = [aws_security_group.rds.id]

  username = "postgres"
  password = random_password.master.result

  multi_az               = var.instance_class != "db.t3.micro"  # No multi-AZ for dev
  deletion_protection    = true
  skip_final_snapshot    = false
  final_snapshot_identifier = "${var.identifier}-final"

  backup_retention_period = 14
  backup_window           = "03:00-04:00"
  maintenance_window      = "Mon:04:00-Mon:05:00"

  performance_insights_enabled = true
  monitoring_interval          = 60

  tags = local.common_tags
}
# modules/rds/outputs.tf
output "endpoint" {
  description = "RDS instance endpoint"
  value       = aws_db_instance.this.endpoint
}

output "port" {
  description = "RDS instance port"
  value       = aws_db_instance.this.port
}

output "security_group_id" {
  description = "Security group ID attached to this RDS instance"
  value       = aws_security_group.rds.id
}

output "secret_arn" {
  description = "ARN of the Secrets Manager secret containing the master password"
  value       = aws_secretsmanager_secret.rds_password.arn
  sensitive   = true
}

Calling the Module from an Environment

# environments/prod/main.tf
terraform {
  required_version = ">= 1.7"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"  # Pin to major version — minor updates auto-apply
    }
  }

  backend "s3" {
    bucket         = "myorg-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"  # Prevents concurrent applies
  }
}

# Reference the VPC from a separate state
data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket = "myorg-terraform-state"
    key    = "prod/vpc/terraform.tfstate"
    region = "us-east-1"
  }
}

module "payments_db" {
  source = "../../modules/rds"

  identifier   = "payments-prod"
  instance_class = "db.r6g.xlarge"
  storage_gb   = 100

  subnet_ids = data.terraform_remote_state.vpc.outputs.private_subnet_ids
  vpc_id     = data.terraform_remote_state.vpc.outputs.vpc_id

  allowed_security_group_ids = [
    module.payments_service.security_group_id
  ]

  tags = {
    Environment = "prod"
    Team        = "payments"
    CostCenter  = "engineering"
  }
}

Terragrunt: DRY Terraform Configurations

Repeating the same backend configuration and provider setup across dozens of module instantiations violates DRY. Terragrunt wraps Terraform to eliminate this repetition:

# terragrunt.hcl (at repository root)
remote_state {
  backend = "s3"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite"
  }
  config = {
    bucket         = "myorg-terraform-state"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite"
  contents  = <<EOF
provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      ManagedBy   = "terraform"
      Repository  = "myorg/infrastructure"
    }
  }
}
EOF
}
# environments/prod/payments-db/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()  # Inherits root terragrunt.hcl
}

terraform {
  source = "../../../modules//rds"  # Double // = module root
}

# Pass inputs to the module
inputs = {
  identifier     = "payments-prod"
  instance_class = "db.r6g.xlarge"
  storage_gb     = 100

  subnet_ids = dependency.vpc.outputs.private_subnet_ids
  vpc_id     = dependency.vpc.outputs.vpc_id
}

# Declare dependency on VPC module output
dependency "vpc" {
  config_path = "../vpc"
  mock_outputs = {
    private_subnet_ids = ["subnet-mock"]
    vpc_id             = "vpc-mock"
  }
  mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}

With Terragrunt, terragrunt run-all plan plans all modules in the directory tree simultaneously, respecting dependencies. terragrunt run-all apply applies them in the correct order.

The directory structure mirrors the account/region/environment hierarchy naturally:

infrastructure/
├── terragrunt.hcl               ← root config, shared by all
├── prod/
│   ├── us-east-1/
│   │   ├── vpc/
│   │   │   └── terragrunt.hcl
│   │   ├── eks/
│   │   │   └── terragrunt.hcl
│   │   └── payments-db/
│   │       └── terragrunt.hcl  ← uses root config, declares vpc dep

Remote State and State Locking

State stored locally is a collaboration and reliability problem. Remote state solves both:

# Bootstrap: create the S3 bucket and DynamoDB lock table first
# (typically in a separate "bootstrap" Terraform config or done manually)
resource "aws_s3_bucket" "terraform_state" {
  bucket = "myorg-terraform-state"
}

resource "aws_s3_bucket_versioning" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id
  versioning_configuration {
    status = "Enabled"  # Keep all state file versions — essential for recovery
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
  }
}

resource "aws_dynamodb_table" "terraform_lock" {
  name         = "terraform-state-lock"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

With remote state, terraform apply acquires a DynamoDB lock first. If another apply is running, it fails immediately instead of corrupting the state file.

Testing Terraform with Terratest

Infrastructure code should be tested like application code. Terratest deploys real infrastructure in a test AWS account, runs assertions, and tears it down:

// test/rds_module_test.go
package test

import (
    "testing"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/gruntwork-io/terratest/modules/aws"
    "github.com/stretchr/testify/assert"
)

func TestRDSModule(t *testing.T) {
    t.Parallel()

    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/rds",
        Vars: map[string]interface{}{
            "identifier":     "test-db-" + uniqueID(),
            "instance_class": "db.t3.micro",  // Cheapest for tests
            "storage_gb":     20,
            "subnet_ids":     getTestSubnetIDs(t),
            "vpc_id":         getTestVPCID(t),
        },
    }

    // Clean up after test regardless of pass/fail
    defer terraform.Destroy(t, terraformOptions)

    // Apply the module
    terraform.InitAndApply(t, terraformOptions)

    // Assert outputs
    endpoint := terraform.Output(t, terraformOptions, "endpoint")
    assert.NotEmpty(t, endpoint)

    // Assert the actual AWS resource
    dbID := "test-db-" + uniqueID()
    db := aws.GetRdsInstanceById(t, dbID, "us-east-1")

    assert.True(t, *db.StorageEncrypted, "Storage should be encrypted")
    assert.True(t, *db.DeletionProtection, "Deletion protection should be enabled")
    assert.Equal(t, int64(14), *db.BackupRetentionPeriod, "Backup retention should be 14 days")
}

Terratest tests are integration tests — they deploy real infrastructure and take 5-15 minutes. Run them on PR to main only, not every commit. Keep a dedicated test AWS account with limited quotas and automated cleanup of orphaned resources.

Drift Detection and Remediation

Infrastructure drift: the actual cloud state diverges from Terraform state. This happens when someone makes a manual change in the AWS console, or a resource is modified by another process.

# Detect drift — shows what changed outside Terraform
terraform plan -refresh-only

# Example output showing drift:
# ~ aws_security_group.rds
#     ingress {
#         + cidr_blocks = ["10.0.1.0/24"]  # Someone added this manually
#     }

Remediate drift in CI with a scheduled plan:

# .github/workflows/drift-detection.yml
name: Terraform Drift Detection

on:
  schedule:
    - cron: '0 */6 * * *'  # Every 6 hours

jobs:
  drift-check:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [dev, staging, prod]

    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "~1.7"

      - name: Terraform Init
        run: terraform init
        working-directory: environments/${{ matrix.environment }}

      - name: Check for Drift
        id: plan
        run: terraform plan -refresh-only -detailed-exitcode -out=drift.plan 2>&1
        working-directory: environments/${{ matrix.environment }}
        continue-on-error: true  # Exit code 2 = drift detected

      - name: Alert on Drift
        if: steps.plan.outputs.exitcode == '2'
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "⚠️ Infrastructure drift detected in ${{ matrix.environment }}. Review: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

The for_each and count Patterns

Creating multiple similar resources without copy-paste:

# for_each: create one resource per map entry (use over count when possible)
variable "environments" {
  default = {
    dev     = { instance_class = "db.t3.micro",  storage_gb = 20 }
    staging = { instance_class = "db.t3.medium", storage_gb = 50 }
    prod    = { instance_class = "db.r6g.xlarge", storage_gb = 100 }
  }
}

resource "aws_db_instance" "this" {
  for_each = var.environments

  identifier     = "myapp-${each.key}"
  instance_class = each.value.instance_class
  allocated_storage = each.value.storage_gb
  # ... other config
}

# Reference specific instances
output "prod_endpoint" {
  value = aws_db_instance.this["prod"].endpoint
}

# Why for_each over count: with count, removing middle item renumbers all later items
# With for_each, removing "staging" only destroys the staging instance
# count is fine for identical resources; for_each for distinct resources

# Dynamic blocks: create nested config blocks programmatically
resource "aws_security_group" "this" {
  name   = "app-sg"
  vpc_id = var.vpc_id

  dynamic "ingress" {
    for_each = var.allowed_ports
    content {
      from_port   = ingress.value
      to_port     = ingress.value
      protocol    = "tcp"
      cidr_blocks = var.allowed_cidrs
    }
  }
}

Terraform Policy as Code with Sentinel / OPA

Before terraform apply touches production, validate that the plan meets organizational policies — no public S3 buckets, no unencrypted databases, required tags on all resources:

# Sentinel policy (HCP Terraform)
# Prevents any S3 bucket from being publicly accessible
import "tfplan/v2" as tfplan

main = rule {
    all tfplan.resource_changes as _, changes {
        changes.type is "aws_s3_bucket" and
        changes.change.after.acl in ["private", null]
    }
}

For open-source (no HCP Terraform), use conftest with OPA Rego:

# policies/required_tags.rego
package terraform

required_tags := {"Environment", "Team", "CostCenter"}

deny[msg] {
    resource := input.resource_changes[_]
    resource.change.actions[_] in ["create", "update"]
    resource_tags := {tag | resource.change.after.tags[tag]}
    missing := required_tags - resource_tags
    count(missing) > 0
    msg := sprintf("Resource %s is missing required tags: %v", [resource.address, missing])
}
# CI check
- name: Generate Terraform Plan JSON
  run: terraform show -json tfplan.binary > plan.json

- name: Policy Check
  run: conftest test plan.json --policy ./policies/
  # Fails the pipeline if any deny rules trigger

CI/CD Pipeline for Terraform

Infrastructure changes need the same review process as application code — but with extra care because mistakes can be irreversible. The standard CI/CD pipeline for Terraform:

# .github/workflows/terraform.yml
name: Terraform Plan / Apply

on:
  pull_request:
    paths: ['environments/**', 'modules/**']
  push:
    branches: [main]
    paths: ['environments/**', 'modules/**']

jobs:
  terraform-plan:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [dev, staging, prod]

    permissions:
      id-token: write  # For OIDC authentication to AWS (no static credentials)
      pull-requests: write

    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/terraform-ci-${{ matrix.environment }}
          aws-region: us-east-1

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "~1.7"

      - name: Terraform Init
        run: terraform init
        working-directory: environments/${{ matrix.environment }}

      - name: Terraform Validate
        run: terraform validate
        working-directory: environments/${{ matrix.environment }}

      - name: Terraform Plan
        id: plan
        run: terraform plan -out=tfplan -no-color 2>&1 | tee plan-output.txt
        working-directory: environments/${{ matrix.environment }}

      - name: Comment Plan on PR
        uses: actions/github-script@v7
        if: github.event_name == 'pull_request'
        with:
          script: |
            const planOutput = require('fs').readFileSync('environments/${{ matrix.environment }}/plan-output.txt', 'utf8')
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Terraform Plan: ${{ matrix.environment }}\n\`\`\`\n${planOutput.slice(0, 65000)}\n\`\`\``
            })

  terraform-apply:
    needs: terraform-plan
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: prod  # GitHub Environment with required reviewers
    runs-on: ubuntu-latest

    steps:
      # ... same init steps
      - name: Terraform Apply
        run: terraform apply -auto-approve tfplan
        working-directory: environments/prod

Key practices:
- OIDC instead of static credentials: AWS IAM roles assumed via OIDC federation — no AWS keys stored in GitHub secrets
- Plan as PR comment: reviewers see exactly what will change before approving
- GitHub Environments with required reviewers: human approval before production apply
- Separate roles per environment: CI role for dev has fewer permissions than prod apply role

Production Considerations

Module Versioning

Once modules are shared across teams, pin versions to prevent unexpected changes:

# Pin to a specific tagged version in a private registry or Git tag
module "rds" {
  source  = "git::https://github.com/myorg/terraform-modules.git//rds?ref=v2.1.0"
  # ... inputs
}

# Or from Terraform Registry
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"  # Accept 5.x but not 6.x
}

Module updates are PRs. Teams subscribe to module changelog. Breaking changes increment the major version.

Import Existing Resources

Migrating existing manually-provisioned infrastructure to Terraform requires importing the existing state without recreating resources. The import block (Terraform 1.5+) makes this declarative:

# Import an existing RDS instance into Terraform management
import {
  to = module.payments_db.aws_db_instance.this
  id = "payments-prod"  # The RDS identifier
}

# Terraform generates the config to match the existing resource
# terraform plan -generate-config-out=generated.tf
# Review generated.tf, clean it up, then add to your config

Before import blocks, the workflow was terraform import command + manually writing the matching config (error-prone). The declarative approach is safer: plan shows what would change before applying.

Organizing Large Configurations with moved Blocks

Renaming or moving resources without destroying and recreating them:

# When you rename a resource (e.g., refactoring module structure),
# use moved blocks to update state without destroying infrastructure
moved {
  from = aws_db_instance.rds
  to   = module.payments_db.aws_db_instance.this
}

Without moved, Terraform destroys the old resource and creates a new one — catastrophic for databases. With moved, it updates the state reference only.

Conclusion

Terraform's advanced patterns solve the problems that flat configurations create as teams and infrastructure grow:
- Module architecture enables code reuse and consistent standards across teams
- Remote state with locking makes collaboration safe and auditable
- Drift detection in CI catches manual changes before they become incidents
- Policy as code prevents compliance violations from reaching production
- Module versioning gives consuming teams stability with an upgrade path

The investment in structure pays for itself the first time a new team member can provision a production database by calling a module with five lines of HCL — without understanding every networking and security detail behind it.

The pattern that prevents the most incidents: terraform plan in CI before every merge to the main branch, terraform apply only after the plan output is reviewed and approved. Infrastructure changes that skip review — "I'll just apply this small tweak manually" — are how configuration drift and outages start. The CI pipeline enforces the discipline when humans are in a hurry.

Terraform's adoption trajectory in 2026 includes a split between OpenTofu (the Linux Foundation fork, created after HashiCorp changed the license) and Terraform (now BSL licensed under HashiCorp). For the community, OpenTofu is a drop-in compatible fork that runs all existing Terraform configurations. The choice between them comes down to licensing requirements and vendor support contracts, not technical capability — they're essentially equivalent for the patterns in this guide.

The universal advice: store state remotely from day one. Version-pin providers. Build modules before you have four copies of the same resource block. Drift detection in CI before drift becomes an incident. These habits are cheap to establish early and expensive to retrofit.

One pattern that accelerates module adoption: provide working examples alongside each module. A examples/basic/ directory with a minimal working instantiation of the module reduces the time to first successful terraform apply from hours to minutes. Teams adopting a new module don't want to read variable documentation — they want to copy a working example and modify it. Modules with examples get adopted; modules without examples get copy-pasted around instead.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-05-21 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Bigger Is Not the Same as Better. The Job That Moved Is the Phone, Not the Lab.

Bigger is a plan. The phone is the receipt. The brief for this cycle is a question: does bigger always mean better in AI? The 2026 answer i...