Engineering Notes
Home/DevOps & Cloud/Terraform/Modules
DevOps & Cloud · Terraform

Modules

Modules package resources behind inputs and outputs so teams stop copy-pasting. Design a good module interface, instantiate it many times with for_each, pin versions, and understand why a module is a namespace, not a security boundary.

☁️ DevOps & Cloud● Intermediate⏱ 4 min read▶ 2 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Root vs child modules and module sources
  • Designing a module: inputs, outputs, safe defaults
  • for_each/count over modules and resource addresses
  • Versioning, releasing and upgrading modules

📚 Before you start

1What a Module Is

Every directory of .tf files is a module. The directory you run Terraform in is the root module; it can call child modules to reuse groups of resources:

hcl
module "vpc" {
  source = "./modules/vpc"            # local path
  name   = local.name_prefix
  cidr   = var.vpc_cidr
  azs    = local.azs
}

module "eks" {
  source  = "terraform-aws-modules/eks/aws"   # public registry
  version = "~> 20.0"                         # ALWAYS pin registry modules
  cluster_name = "${local.name_prefix}-eks"
  vpc_id       = module.vpc.vpc_id             # use the child module's outputs
  subnet_ids   = module.vpc.private_subnet_ids
}

A module's interface is its variables (inputs) and outputs; its resources are an implementation detail.

2Module Sources

SourceExampleNotes
local path./modules/vpcsame repo, versioned with the caller
Terraform Registryterraform-aws-modules/vpc/aws + versionsemantic versions
Gitgit::https://github.com/acme/tf-modules.git//vpc?ref=v1.4.0pin a tag or commit
private registryapp.terraform.io/acme/vpc/awsHCP Terraform / Artifactory
S3 / GCS / HTTP archivess3::https://…/vpc.zipless common

Run terraform init (or init -upgrade) after changing sources/versions.

3Designing a Good Module

text
modules/service/
  main.tf         # resources
  variables.tf    # inputs with types, descriptions, validation, sane defaults
  outputs.tf      # what callers need (ids, arns, urls)
  versions.tf     # required_providers (no provider block inside reusable modules!)
  README.md       # usage example (terraform-docs can generate inputs/outputs tables)
  examples/basic/ # a runnable example, also used by tests

Guidelines:

  • One clear responsibility at a useful level of abstraction ("a service with its ALB target group, ECS service, log group and alarms"), not a thin wrapper around a single resource.
  • Minimal, typed inputs with secure defaults (encryption on, public access off); escape hatches (extra_tags, additional_policies) instead of exposing every argument.
  • Don't configure providers in reusable modules — the caller passes them (use configuration_aliases when a module needs multiple regions/accounts).
  • Outputs for everything a caller might wire up.
  • Composition over nesting: root modules compose several flat modules; deep module trees are hard to debug.
A 'wrapper of everything' module
hcl
module "bucket" {
  source = "./modules/s3"
  acl                  = var.acl
  versioning           = var.versioning
  public_access_block  = var.pab   # 40 pass-through vars…
}

It mirrors the resource 1:1, adds nothing, and lets callers make it public.

An opinionated module
hcl
module "uploads" {
  source  = "git::https://github.com/petpal/tf-modules//secure-bucket?ref=v2.3.0"
  name    = "petpal-prod-uploads"
  retention_days = 365
}
# encryption, versioning, public-access block and TLS-only policy
# are always on; no input can turn them off

4Repeating Modules

hcl
module "service" {
  for_each = var.services                 # one module instance per service
  source   = "./modules/service"
  name     = each.key
  image    = each.value.image
  cluster  = aws_ecs_cluster.main.id
  vpc      = module.vpc
}
# addresses: module.service["api"].aws_ecs_service.this

5Versioning and Releasing Modules

  • Shared modules live in their own repo (or a monorepo folder) with semantic version tags; consumers pin versions and upgrade deliberately.
  • Breaking changes (renamed inputs, resources that would be replaced) → major version; include moved blocks in the module so callers upgrading don't destroy resources.
  • Test modules before release (09-testing_and_policy_as_code/).
Under the HoodModules Are Namespaces, Not Boundaries▾

When Terraform loads a configuration, it flattens all modules into one graph: module.vpc.aws_subnet.private[0] is just a resource with a longer address. Module inputs are evaluated in the caller's scope and become variables inside the module; outputs become references the caller can use. Dependencies cross module boundaries at the level of individual values, so a resource in module B can start as soon as the specific output of module A it uses is known — not after all of module A. A module adds no runtime isolation: all its resources share the root module's state file, providers and apply. Real isolation (blast radius, separate locks) comes only from separate root modules / states (03-state_and_remote_backends/ Part 2).

A root module calls child modules for the VPC, database and one service instance per entry with for_each, wiring outputs to inputs; Terraform flattens them into a single graph and state, while shared modules are versioned and pinned
A root module calls child modules for the VPC, database and one service instance per entry with for_each, wiring outputs to inputs; Terraform flattens them into a single graph and state, while shared modules are versioned and pinned

⚠️ Common Pitfalls

  1. Unpinned registry/Git modules — surprise changes on init -upgrade.
  2. Provider blocks inside reusable modules.
  3. God modules with 80 inputs mirroring every resource argument.
  4. Wrapper modules around one resource with no added value.
  5. Breaking changes without moved blocks — callers' resources get replaced.
  6. Deeply nested modules — hard to read plans and debug.

📋 Quick Reference

text
module "x" { source = "…", version = "~> 1.2", inputs… } → module.x.output_name
sources: local · registry (+version) · git::…?ref=tag · private registry
module layout: main.tf, variables.tf, outputs.tf, versions.tf, README, examples/
no provider blocks in reusable modules · secure defaults · for_each over modules
semver releases + moved blocks for refactors

🧠 Key takeaways

  • A module is a directory of .tf files with inputs and outputs; every config has a root module.
  • Good modules are opinionated (secure defaults that can't be disabled) with a small interface.
  • Use for_each with stable keys to repeat modules; addresses include the key.
  • Pin versions (registry versions or git tags), release with changelogs, and remember modules are namespaces, not security boundaries.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What makes a good Terraform module?core▾

A focused purpose, a clear interface (typed variables with descriptions and validation, useful outputs), sensible secure defaults, no hard-coded providers/credentials, versioned releases, examples and tests.

Follow-ups interviewers ask
  • Too small modules? Thin wrappers over one resource add indirection without value.
Q2How do you version and source modules?core▾

Publish to a registry (public or private) or Git tags, and pin versions in source/version. Upgrade via PRs with plan review.

Follow-ups interviewers ask
  • Why pin? Unpinned modules can change behaviour unexpectedly.
Q3Root modules vs child modules?senior▾

The root module is the directory you run Terraform in (with backend and providers); child modules are reusable components called from it.

Follow-ups interviewers ask
  • Should child modules configure providers? No; pass providers from the root.
Q4How do you compose modules?core▾

Pass outputs of one module as inputs to another (VPC module outputs subnet IDs to an EKS module), keeping dependencies explicit and avoiding modules reaching into each other's internals.

Follow-ups interviewers ask
  • Nested modules depth? Keep shallow for readability.
Q5How do you test modules?core▾

terraform validate, tflint, terraform test with plan/apply assertions, Terratest for integration tests in sandbox accounts, and policy checks (Checkov, tfsec).

Follow-ups interviewers ask
  • What does terraform test do? Runs .tftest.hcl files with assertions against plans or real applies.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Why prefer for_each over count for repeated modules?
count uses positional indexes.
2. How should a module from Git be referenced in production?
Reproducible and reviewable upgrades.
3. A good security-focused module…
Opinionated beats pass-through.
4. Can a module stop its caller from reading its resources' secrets in state?
Everything lands in the same state.