StackPractices
intermediate By Mathias Paulenko

Infrastructure as Code Review Template

A template for reviewing Terraform and CloudFormation infrastructure code.

Topics: devops

Overview

Infrastructure code is software, and it deserves the same review discipline as application code — one misconfigured security group or an overly permissive IAM policy can expose your whole environment. This template gives you a structured review process for Terraform, CloudFormation, Pulumi, or Ansible changes, so nothing dangerous slips through on a busy Friday.

When to Use

Use this resource when:

  • Adding a new Terraform module or CloudFormation stack to production
  • Reviewing pull requests that modify infrastructure
  • Auditing existing infrastructure code for security or cost issues

Solution

# Infrastructure as Code Review: `<Module / Stack>`

## 1. Change Metadata

| Field | Value |
|-------|-------|
| Module / Stack | `name` |
| Tool | `Terraform / CloudFormation / Pulumi / Ansible` |
| Environment | `dev / staging / prod` |
| Ticket | `JIRA-1234` |
| Author | `@author` |
| Reviewer | `@reviewer` |
| Risk Level | `Low / Medium / High / Critical` |

## 2. Static Analysis

- [ ] `terraform validate` or `cfn-lint` passes with zero errors
- [ ] `terraform plan` or `change set` has been reviewed for unexpected deletions
- [ ] Security scan (Checkov, tfsec, cfn-nag) has zero HIGH/CRITICAL findings
- [ ] Cost estimate provided for new resources (Infracost or manual)
- [ ] State file locking is configured for Terraform
- [ ] Backend configuration uses a remote, encrypted state store

## 3. Security Review

| Check | Pass / Fail | Notes |
|-------|-------------|-------|
| No hardcoded secrets in code or variables | | |
| Least-privilege IAM / RBAC roles | | |
| Security groups restrict ingress to known CIDRs | | |
| Encryption at rest enabled for storage | | |
| Encryption in transit enforced (TLS 1.2+) | | |
| Public access disabled by default | | |
| Logging enabled for all data planes | | |
| WAF / DDoS protection for public endpoints | | |

## 4. Reliability & Operations

| Check | Pass / Fail | Notes |
|-------|-------------|-------|
| Resource limits / quotas checked | | |
| Health checks and auto-recovery configured | | |
| Multi-AZ or multi-region redundancy where required | | |
| Backup / snapshot policy defined | | |
| Monitoring and alerting included | | |
| Graceful shutdown / draining for stateful services | | |
| Idempotency verified: re-run produces no changes | | |

## 5. Cost & Efficiency

| Check | Pass / Fail | Notes |
|-------|-------------|-------|
| Right-sized instances (not default / max) | | |
| Reserved capacity or savings plans considered | | |
| Unused resources removed in this change | | |
| Storage lifecycle policies defined | | |
| Data transfer costs estimated | | |

## 6. Documentation

- [ ] README updated with inputs, outputs, and usage example
- [ ] Architecture Decision Record (ADR) included for major changes
- [ ] Runbook updated for new operational procedures
- [ ] On-call alert playbooks cover new monitoring signals

## 7. Rollback Plan

| Scenario | Rollback Action | Time to Complete |
|----------|-----------------|------------------|
| Deployment failure | Re-apply previous state or revert the merge — avoid `destroy -target` on stateful resources | 15 min |
| Performance regression | Revert to previous image / scale up | 10 min |
| Security incident | Disable public access + revoke keys | 5 min |

Explanation

Reviewing infrastructure isn’t like reviewing application code — the blast radius is bigger. A bug in app code hurts one pod; a bug in Terraform can delete a database or put it on the public internet. That’s why the template splits the review into three passes: static analysis (the automated checks), security review (human judgment), and operational readiness (can you actually run this thing and recover when it breaks?). The rollback plan isn’t optional either — every infra change has to be reversible inside the RTO of whatever it supports.

The flow looks like this:

flowchart diagram: PR opened

How to Adopt This Template

Don’t paste the whole checklist into every PR and call it done — calibrate it to the risk level. A new module or anything touching prod state deserves the full review end to end. A tag change or a non-prod tweak only needs the static analysis section plus a quick security pass; heavyweight review on trivia just trains people to rubber-stamp.

A rollout that works: put static analysis and the security scan in CI first, build the plan-review habit for a couple of weeks, then make the full checklist mandatory for production changes. If your team already uses a generic review process (see the code review checklist), keep both — that one catches logic bugs, this one catches blast-radius mistakes.

Variants

ToolStatic AnalysisSecurity ScanState Management
Terraformterraform validate, fmtCheckov, tfsec, TerrascanRemote S3 backend + locking
CloudFormationcfn-lint, cfn-guardcfn-nag, CheckovStack sets + drift detection
Pulumipulumi previewCheckovPulumi Cloud state
Ansibleansible-lint, syntax-checkAnsible hardening rolesGit + AWX / Tower

What works

A few practices that make these reviews stick in real teams:

  1. Run static analysis in CI/CD before a human ever sees the pull request
  2. Require two approvals for production infrastructure changes, not one
  3. Review the terraform plan diff, not just the code; plans reveal destructive changes
  4. Separate state files per environment; never share prod and dev state
  5. Use module versioning; pin provider and module versions to avoid surprise updates

Common Mistakes

  1. Reviewing only the code diff and ignoring the terraform plan output
  2. Hardcoding secrets instead of using a secret manager (Vault, AWS Secrets Manager)
  3. Using count or for_each on stateful resources without considering data loss on destroy
  4. Forgetting to update documentation when the infrastructure changes
  5. Running terraform apply locally instead of through a CI/CD pipeline with audit logging

Advanced Solutions

Automated IaC scanning with Checkov in CI/CD

Integrate infrastructure scanning into your pipeline to catch issues before review:

# .github/workflows/terraform-review.yml
name: Terraform Security Scan
on:
  pull_request:
    paths:
      - "terraform/**"
      - "infrastructure/**"

jobs:
  checkov-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: bridgecrewio/checkov-action@v12
        with:
          directory: terraform/
          framework: terraform
          output_format: sarif
          output_file_path: results.sarif
          soft_fail: false
      - uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: results.sarif

  terraform-plan-review:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-tf
          aws-region: us-east-1
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7.0"
      - name: Terraform Init
        run: terraform -chdir=terraform/ init -input=false
      - name: Terraform Plan
        run: terraform -chdir=terraform/ plan -input=false -out=tfplan
      - name: Post Plan to PR
        uses: actions/github-script@v7
        with:
          script: |
            const { execSync } = require('child_process');
            const planOutput = execSync('terraform -chdir=terraform/ show -no-color tfplan').toString();
            const truncated = planOutput.substring(0, 50000);
            github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.payload.pull_request.number,
              body: `## Terraform Plan\n\n\`\`\`diff\n${truncated}\n\`\`\``
            });

Custom Checkov policy for organization-specific rules

Define custom policies that enforce your organization’s standards:

# checkov_custom_policies/aws_require_tags.py
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import CheckResult, CheckCategories

class RequireCostCenterTag(BaseResourceCheck):
    def __init__(self):
        name = "Ensure all resources have a CostCenter tag"
        check_id = "CKV_AWS_CUSTOM_1"
        supported_resources = ["aws_*"]
        categories = [CheckCategories.CONVENTION]
        super().__init__(name=name, id=check_id, categories=categories,
                         supported_resources=supported_resources)

    def scan_resource_conf(self, conf):
        tags = conf.get("tags", [{}])[0]
        if "CostCenter" in tags and "Environment" in tags:
            return CheckResult.PASSED
        return CheckResult.FAILED

check = RequireCostCenterTag()
# Run custom policies alongside built-in checks
checkov -d terraform/ \
  --external-checks-dir checkov_custom_policies/ \
  --framework terraform \
  --output cli \
  --soft-fail false

Terraform module testing with Terratest

Validate that your infrastructure actually works by writing automated tests:

// test/infrastructure_test.go
package test

import (
    "testing"
    "time"

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

func TestVpcModule(t *testing.T) {
    terraformOptions := &terraform.Options{
        TerraformDir: "../terraform/modules/vpc",
        Vars: map[string]interface{}{
            "environment":    "test",
            "vpc_cidr":       "10.0.0.0/16",
            "enable_nat":     true,
        },
    }

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    vpcId := terraform.Output(t, terraformOptions, "vpc_id")
    assert.NotEmpty(t, vpcId)

    subnetIds := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
    assert.Equal(t, 2, len(subnetIds))

    // Verify NAT gateway was created
    natGatewayId := terraform.Output(t, terraformOptions, "nat_gateway_id")
    assert.NotEmpty(t, natGatewayId)

    // Verify VPC has DNS support
    aws.AssertVpcDnsSupportEnabled(t, "us-east-1", vpcId)
}

Drift detection automation

Schedule regular drift checks and alert when infrastructure diverges from code:

import subprocess
import smtplib
from email.mime.text import MIMEText
from typing import List, Dict

class DriftDetector:
    def __init__(self, terraform_dir: str, environments: List[str]):
        self.terraform_dir = terraform_dir
        self.environments = environments

    def check_drift(self, env: str) -> Dict:
        """Check for drift in a specific environment."""
        result = {"environment": env, "drifted": False, "resources": []}

        try:
            subprocess.run(
                ["terraform", f"-chdir={self.terraform_dir}/{env}", "init", "-input=false"],
                check=True, capture_output=True
            )
            subprocess.run(
                ["terraform", f"-chdir={self.terraform_dir}/{env}", "refresh", "-input=false"],
                check=True, capture_output=True
            )
            plan = subprocess.run(
                ["terraform", f"-chdir={self.terraform_dir}/{env}", "plan", "-detailed-exitcode", "-input=false"],
                capture_output=True, text=True
            )

            if plan.returncode == 2:
                result["drifted"] = True
                result["resources"] = self._parse_drifted_resources(plan.stdout)
        except subprocess.CalledProcessError as e:
            result["error"] = str(e)

        return result

    def _parse_drifted_resources(self, plan_output: str) -> List[str]:
        """Extract resource addresses from plan output."""
        resources = []
        for line in plan_output.split("\n"):
            if line.startswith("  # ") and "will be" in line:
                resources.append(line.strip())
        return resources

    def send_alert(self, drift_results: List[Dict]) -> None:
        """Send email alert if drift is detected."""
        drifted_envs = [r for r in drift_results if r["drifted"]]
        if not drifted_envs:
            return

        body = "Drift detected in the following environments:\n\n"
        for env in drifted_envs:
            body += f"Environment: {env['environment']}\n"
            for resource in env["resources"]:
                body += f"  - {resource}\n"
            body += "\n"

        msg = MIMEText(body)
        msg["Subject"] = "[ALERT] Terraform Drift Detected"
        msg["From"] = "infra-alerts@example.com"
        msg["To"] = "platform-team@example.com"

        with smtplib.SMTP("smtp.example.com", 587) as server:
            server.send_message(msg)

# Example usage
detector = DriftDetector("terraform/environments", ["prod", "staging", "dev"])
results = [detector.check_drift(env) for env in detector.environments]
detector.send_alert(results)

Further Reading

Frequently Asked Questions

Should infrastructure changes require the same approval as application deployments?

Often they should require more scrutiny. Application changes can be rolled back with a deployment; infrastructure changes can destroy data. Consider a separate approval workflow for production infrastructure, or require a senior engineer sign-off.

How do I review a large Terraform module without missing details?

Break the review into layers: first static analysis and plan review, then security checks, then operational readiness. Don't try to review everything at once. Use a checklist (like this template) so no category is skipped.

What is drift detection and why does it matter?

Drift occurs when someone changes infrastructure outside of IaC (e.g., via the console). Tools like Terraform refresh, AWS Config, or CloudFormation drift detection identify these changes. Review drift reports regularly; otherwise your code and reality diverge, making future changes dangerous.

Which scanning tools should I run for each IaC tool?

For Terraform, Checkov or tfsec. For CloudFormation, cfn-lint plus cfn-nag or cfn-guard. For Ansible, ansible-lint. Whatever you pick, wire it into CI so it runs on every pull request — a scan that depends on someone remembering to run it stops happening within a month.

Who should approve production infrastructure changes?

At least one engineer who didn't write the code, and a second approval for anything that touches prod state, IAM, or networking. The checklist catches the mechanical stuff; the approver needs enough context to catch what the tools can't.