Layer 3 - modules: Reusable Building Blocks

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):

HCL
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:

HCL
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:

HCL
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_count makes N copies. Inside, count.index is 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):

HCL
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:

HCL
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:

HCL
nat_gateway_id = var.single_nat_gateway ? aws_nat_gateway.this[0].id
                                        : aws_nat_gateway.this[count.index].id

The 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:

HCL
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.name reads the current region (so the code is region-agnostic).
  • aws_route_table.app[*].id is a splat expression - [*] collects the id from every copy created by count into 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:

HCL
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/networking definition, 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:

HCL
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.

Adesh Tamrakar
SOFTWARE ENGINEER · VAULT

Notes, insights and random discoveries from a working engineer's vault - written for future me, published for you.