This is where the real infrastructure is defined. A module is a self-contained
package: inputs (variables.tf), resources (main.tf), and outputs
(outputs.tf). Roots call modules; modules can call other modules.
The README lists which modules are implemented vs still stubs (placeholders to be filled in later):
- Implemented:
networking,iac-pipeline,org-foundation,guardrails,org-cloudtrail,identity-center,budgets,account-baseline. - Stubs:
ecs,rds,redis,s3,edge,iam,observability.
We'll study the most instructive implemented module in depth, then look at a tiny one, then explain what "stub" means.
11.1 The networking module - a full worked example
modules/networking/main.tf builds the 3-tier VPC. It
is the best file in the repo for learning HCL because it uses most of the language
features in one place. Let's walk through it.
Discover the AZs dynamically (don't hard-code zone names):
data "aws_availability_zones" "available" { state = "available" }
locals {
azs = slice(data.aws_availability_zones.available.names, 0, var.az_count)
}slice(list, 0, n) takes the first n AZ names. So az_count = 2 picks the first
two AZs of whatever region you're in.
The VPC itself:
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr # e.g. 10.10.0.0/16
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(local.tags, { Name = "${var.name_prefix}-vpc" })
}(The local name "this" is a common convention for "the main resource of this
module".)
Subnets via count - instead of writing three near-identical subnet blocks per
tier, it creates az_count copies of each tier with count:
resource "aws_subnet" "app" {
count = var.az_count
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, local.tiers.app + count.index)
availability_zone = local.azs[count.index]
tags = merge(local.tags, {
Name = "${var.name_prefix}-app-${local.azs[count.index]}"
Tier = "app"
})
}count = var.az_countmakes N copies. Inside,count.indexis 0, 1, 2…cidrsubnet(prefix, newbits, netnum)carves a smaller CIDR out of the VPC's range - this is how each subnet gets a distinct, non-overlapping IP range automatically, instead of you computing IP math by hand.local.azs[count.index]spreads the copies across different AZs.
The three tiers (public, app, data) each get this treatment, offset into
different parts of the address space via local.tiers = { public = 0, app = 10, data = 20 }.
Internet access wiring - the public tier gets an Internet Gateway and a route
to 0.0.0.0/0 (all internet traffic):
resource "aws_internet_gateway" "this" { vpc_id = aws_vpc.this.id ... }
resource "aws_route" "public_internet" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0" # "anywhere on the internet"
gateway_id = aws_internet_gateway.this.id
}NAT for the app tier - the number of NAT Gateways depends on a cost/HA toggle:
locals {
nat_count = var.single_nat_gateway ? 1 : var.az_count
}This is a conditional (ternary) expression: condition ? if_true : if_false.
Dev sets single_nat_gateway = true (one shared NAT - cheaper); Prod would set it
false (one NAT per AZ - highly available). One toggle, two cost/reliability
profiles, same code. The route then picks the right NAT:
nat_gateway_id = var.single_nat_gateway ? aws_nat_gateway.this[0].id
: aws_nat_gateway.this[count.index].idThe data tier has no NAT at all - it is deliberately isolated (database and cache shouldn't reach the internet). Its only outside path is a free S3 Gateway endpoint:
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.this.id
service_name = "com.amazonaws.${data.aws_region.current.name}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = concat(aws_route_table.app[*].id, aws_route_table.data[*].id)
}data.aws_region.current.namereads the current region (so the code is region-agnostic).aws_route_table.app[*].idis a splat expression -[*]collects theidfrom every copy created bycountinto a list.concat(list1, list2)joins two lists.
Optional interface endpoints via for_each - when enabled, it creates one
endpoint per AWS service in a list:
locals {
interface_endpoint_services = var.enable_interface_endpoints ? [
"ecr.api", "ecr.dkr", "logs", "secretsmanager", "ssm",
] : []
}
resource "aws_vpc_endpoint" "interface" {
for_each = toset(local.interface_endpoint_services)
service_name = "com.amazonaws.${data.aws_region.current.name}.${each.value}"
...
}for_each is like count but iterates over a set/map instead of a number; each
instance is keyed by each.value. toset(list) converts the list to a set (the
type for_each wants). When enable_interface_endpoints is false the list is
empty, so zero endpoints are created - another clean cost toggle.
The module's modules/networking/outputs.tf then exposes the IDs other
modules need: vpc_id, app_subnet_ids, data_subnet_ids, public_subnet_ids,
etc. Those become the wiring points in the env root.
Takeaway: one
modules/networkingdefinition, driven by four input variables (vpc_cidr,az_count,single_nat_gateway,enable_interface_endpoints), produces a lean dev network or a hardened prod network with no code duplication. That is the whole point of modules.
11.2 A tiny module - account-baseline
modules/account-baseline/main.tf shows that
modules don't have to be big. It just sets three account-wide safety defaults:
resource "aws_s3_account_public_access_block" "this" {
count = var.block_public_s3 ? 1 : 0 # create only if the toggle is on
block_public_acls = true
...
}
resource "aws_ebs_encryption_by_default" "this" {
count = var.default_ebs_encryption ? 1 : 0
enabled = true
}
resource "aws_iam_account_password_policy" "this" {
minimum_password_length = var.password_minimum_length
require_symbols = true
...
}Note the count = condition ? 1 : 0 idiom: a common way to make a single resource
optional - create one copy when the toggle is true, zero when false. The dev
root calls this with no arguments (module "baseline" { source = "..." }),
relying entirely on the module's defaults.
11.3 The iac-pipeline module - covered with cicd
The iac-pipeline module builds a CodePipeline; it's most useful to read together
with the cicd/ root that calls it, so it's covered in section 13.
11.4 What "stub" means here
Modules marked [stub] (ecs, rds, redis, s3, edge, iam,
observability) have the right file structure and interface (variables/outputs)
but their resources aren't fully built out yet. They exist so that the env root can
already wire everything together (see the next section), and the implementations
get filled in over time. The env root already references their outputs (e.g.
module.rds.db_secret_arn), so when a stub is completed, nothing upstream needs to
change.