Week 20: Advanced Terraform — Testing, Workspaces & Policy as Code

Weeks 9–10 got you to Terraform that applies cleanly with remote state. That's enough for a solo capstone; it's not enough for a team of ten changing the same infrastructure daily. This week closes that gap: workspaces and Terragrunt for managing several environments from one codebase without copy-pasting modules, Terratest for actually testing that a module does what it claims, and policy as code for blocking infrastructure that violates a rule before it ever gets applied.

Module 17 of 22 Week 20 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Manage multiple environments from one Terraform codebase with workspaces or Terragrunt
  • Write an automated test for a Terraform module with Terratest
  • Block a non-compliant plan with an OPA/Conftest policy

1. Workspaces & Terragrunt

Terraform workspaces let one configuration manage multiple named instances of the same infrastructure, each with its own state file, switched with terraform workspace select. It's built into core Terraform and needs no extra tooling, but it has a sharp edge: workspaces share the exact same .tf files, so any environment-specific difference has to be threaded through variables and terraform.workspace conditionals, which gets messy fast once staging and production genuinely diverge.

terminal — workspaces for staging vs. production
terraform workspace new staging
terraform workspace new production

terraform workspace select staging
terraform apply -var-file=staging.tfvars

terraform workspace select production
terraform apply -var-file=production.tfvars

Terragrunt takes a different approach that scales better once you have real per-environment differences: it's a thin wrapper around Terraform that generates backend and provider configuration and lets each environment live in its own directory, each pointing at the same underlying module.

directory layout & a Terragrunt environment config
infra/
├── modules/vpc/              # the actual Terraform module, environment-agnostic
├── staging/vpc/terragrunt.hcl
└── production/vpc/terragrunt.hcl
production/vpc/terragrunt.hcl
terraform {
  source = "../../modules/vpc"
}

remote_state {
  backend = "s3"
  config = {
    bucket = "acme-tfstate"
    key    = "production/vpc/terraform.tfstate"
    region = "us-east-1"
  }
}

inputs = {
  cidr_block  = "10.1.0.0/16"
  environment = "production"
  instance_count = 3
}

Run terragrunt apply from the production/vpc directory and it generates the backend block, injects the inputs, and calls Terraform against the shared module — the module code itself never needs an if environment == "production" conditional anywhere inside it, because the difference lives entirely in each environment's own small config file.

Workspaces are the trap when environments genuinely differ in shape

Workspaces are the right tool when environments are truly identical except for a handful of variable values. The moment staging needs a resource production doesn't (a smaller VPC, no multi-AZ), workspaces force that difference into conditionals scattered through the shared .tf files — Terragrunt's separate directories per environment handle that divergence far more cleanly.

2. Automated Testing with Terratest

terraform plan tells you what will change; it says nothing about whether the module is actually correct — whether the security group it creates really only opens the ports you intended, or whether the VPC it provisions actually has working internet access. Terratest, a Go library, applies a module against real (throwaway) infrastructure and asserts on the result, then destroys it — an actual integration test, not a static check.

vpc_test.go — a minimal Terratest test
package test

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

func TestVpcModule(t *testing.T) {
	opts := &terraform.Options{
		TerraformDir: "../modules/vpc",
		Vars: map[string]interface{}{
			"cidr_block":  "10.99.0.0/16",
			"environment": "test",
		},
	}

	defer terraform.Destroy(t, opts)      // always tears down, even on failure
	terraform.InitAndApply(t, opts)

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

	subnetCount := terraform.OutputList(t, opts, "public_subnet_ids")
	assert.Len(t, subnetCount, 2)          // module is supposed to create exactly 2
}

defer terraform.Destroy is the line that makes this safe to run repeatedly in CI — it guarantees the throwaway VPC gets torn down even if an assertion fails partway through, so a failing test doesn't leave orphaned billable infrastructure behind. Wiring this into the GitHub Actions pipeline from Week 6 means a pull request that changes a Terraform module gets its actual behavior verified against real cloud resources, not just a syntax check.

Terratest costs real money and real time — reserve it for modules that need it

Every Terratest run provisions genuine cloud resources, which means it's slower and billable compared to `terraform validate` or `plan`. It's worth it for a module reused across many teams where a subtle bug has wide blast radius; it's usually not worth the CI minutes for a one-off resource block nobody else depends on.

3. Policy as Code with OPA/Conftest

Least-privilege IAM from Week 14 relies on someone remembering the rule during code review. Policy as code makes the rule mechanically enforced: the Open Policy Agent (OPA) and its CLI, Conftest, evaluate a Terraform plan's JSON output against rules written in Rego, and can fail a CI job before a non-compliant plan is ever applied.

policy/s3.rego — deny any public S3 bucket
package main

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_s3_bucket_public_access_block"
  resource.change.after.block_public_acls == false
  msg := sprintf("S3 bucket '%s' must block public ACLs", [resource.address])
}

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_security_group_rule"
  resource.change.after.cidr_blocks[_] == "0.0.0.0/0"
  resource.change.after.from_port == 22
  msg := sprintf("Security group rule '%s' opens SSH to the entire internet", [resource.address])
}
terminal — running the policy against a real plan
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
conftest test plan.json --policy policy/

# FAIL - main - S3 bucket 'aws_s3_bucket.uploads' must block public ACLs

The second rule is a direct, mechanical version of a habit Week 8 taught by instruction alone — "don't open port 22 to 0.0.0.0/0." Policy as code turns that from a reviewer's memory into something CI enforces on every single plan, for every engineer, without anyone needing to remember to check it by eye. Wired into the same pipeline as Terratest, a PR now fails for either reason: the module doesn't behave correctly, or it behaves correctly but violates an organizational rule.

Write policies against the plan JSON, not the raw HCL

Evaluating the rendered plan (terraform show -json) rather than the source .tf files means the policy sees the actual final values after every variable, module input and default is resolved — a rule written against raw HCL can miss a violation that only becomes visible once a variable's default value is applied.

4. Hands-on Exercise

Hands-on

Restructure a module for multi-environment use, test it, and gate it with policy

Take a Terraform module from Week 9–10 through all three practices this week.

Requirements:

  1. Restructure one existing module into a Terragrunt layout with at least two environment directories, each with its own terragrunt.hcl and remote state key.
  2. Write a Terratest test that applies the module against real infrastructure, asserts on at least two outputs, and confirms resources are destroyed even when you deliberately make an assertion fail.
  3. Write two Conftest/Rego policies of your own — one blocking a public S3 bucket, one blocking an open SSH rule — and run them against a real terraform plan from your infrastructure.
  4. Deliberately introduce a violation (an open SSH rule) and confirm Conftest fails the check with a clear message identifying the offending resource.
Hint

terraform show -json tfplan | jq '.resource_changes[].type' lists every resource type in a plan — a quick way to find the exact field names Conftest needs to match against before writing the full Rego rule.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

Why do Terraform workspaces become harder to manage once staging and production genuinely differ in shape, not just values?

All workspaces share the same .tf files, so any structural difference between environments — a resource one environment needs that another doesn't — has to be expressed as a conditional inside that shared code. As real environments diverge, those conditionals accumulate and the shared module gets harder to reason about, which is exactly the problem Terragrunt's separate-directory-per-environment structure avoids.

Q2

Why does defer terraform.Destroy(t, opts) matter in a Terratest test?

Terratest applies real, billable cloud infrastructure to run its assertions against. The defer guarantees the destroy step runs even if an assertion later in the test function fails and the test exits early — without it, a failing test would leave the throwaway infrastructure running and accumulating cost indefinitely.

Q3

Why evaluate an OPA/Conftest policy against terraform show -json output instead of the raw .tf source files?

The plan JSON reflects the fully resolved final values — after every variable, module input and default has been applied — while the raw HCL might reference a variable whose actual value only becomes clear at plan time. A policy written against raw source can miss a violation that only appears once defaults resolve; evaluating the rendered plan guarantees the policy sees what will actually be created.