Capstone — Build the Colony
Time for the real thing. You'll build a reusable "villa" module, instantiate it across three cities (dev, staging, prod), wire in variables and outputs, and run the complete lifecycle — plan, apply, show, destroy. Plan for about an hour.
By the end, one module definition has produced three separate S3 storage sheds with correct names, tags, and outputs — and every resource is cleanly destroyed.
Step 1 · Scaffold the Project
mkdir colony && cd colony
mkdir modules/villa
Project layout:
colony/
├── main.tf # calls the module three times
├── variables.tf # city names, project name
├── outputs.tf # bucket names + ARNs
└── modules/
└── villa/
├── main.tf # the reusable blueprint
├── variables.tf
└── outputs.tf
Step 2 · Write the Villa Module
modules/villa/main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
resource "aws_s3_bucket" "shed" {
bucket = var.bucket_name
tags = var.tags
}
modules/villa/variables.tf:
variable "bucket_name" {
type = string
description = "Globally unique bucket name for this villa"
}
variable "tags" {
type = map(string)
default = {}
description = "Tags applied to the bucket"
}
modules/villa/outputs.tf:
output "shed_arn" {
value = aws_s3_bucket.shed.arn
}
A module is just a folder of .tf files that other configurations can call — the reusable blueprint. Everything a caller must provide is a variable; everything a caller needs back is an output.
Step 3 · Call the Module for Three Cities
main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
}
}
provider "aws" {
region = var.region
}
resource "random_string" "suffix" {
length = 6
special = false
}
module "villa" {
source = "./modules/villa"
for_each = toset(var.cities)
bucket_name = "campus-library-${each.key}-${random_string.suffix.result}"
tags = {
Project = var.project
City = each.key
ManagedBy = "Terraform"
}
}
variables.tf:
variable "project" {
type = string
default = "Campus Library"
}
variable "region" {
type = string
default = "eu-west-1"
}
variable "cities" {
type = list(string)
default = ["dev", "staging", "prod"]
}
outputs.tf:
output "shed_arns" {
description = "ARN of each city's shed"
value = { for city in var.cities : city => module.villa[city].shed_arn }
}
for_each on a module creates one instance per city. The { for ... in ... } output builds a map of city → ARN in one expression.
Step 4 · The Full Lifecycle
terraform init
terraform fmt
terraform validate
terraform plan
Expected:
Plan: 3 to add, 0 to change, 0 to destroy.
terraform apply
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
(Three buckets + one random suffix.)
terraform output
shed_arns = {
"dev" = "arn:aws:s3:::campus-library-dev-a1b2c3"
"prod" = "arn:aws:s3:::campus-library-prod-a1b2c3"
"staging" = "arn:aws:s3:::campus-library-staging-a1b2c3"
}
Step 5 · Inspect and Admire
terraform state list
terraform state show "module.villa[\"prod\"].aws_s3_bucket.shed"
You're reading the register of a three-city colony, all built from one module.
Step 6 · Extend It (Optional)
Pick one:
- Add a
versioningargument to the bucket - Add a
server_side_encryption_configurationblock - Move state to a remote S3 backend + DynamoDB lock
- Add a fourth city:
terraform plan -var cities=...
Step 7 · Demolition
terraform destroy
Type yes, then confirm in the AWS console that all three buckets are gone.
Check Yourself
- A module built and called from a parent config
- Module instantiated 3× via for_each
- Variables with types and defaults
- A computed map output
- init → fmt → validate → plan → apply → show → destroy
Done — you've built (and demolished) a colony from a reusable blueprint. If any term felt fuzzy, check the Glossary. To see how real teams organize this in code review, revisit the workflow ritual and the Git Deep Dive.