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.
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.
infra/
├── modules/vpc/ # the actual Terraform module, environment-agnostic
├── staging/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 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.
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.
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.
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])
}
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.
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
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:
- Restructure one existing module into a Terragrunt layout with at least two environment directories, each with its own
terragrunt.hcland remote state key. - 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.
- 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 planfrom your infrastructure. - Deliberately introduce a violation (an open SSH rule) and confirm Conftest fails the check with a clear message identifying the offending resource.
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?
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?
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?
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.