terraform-module-design · git:20260708.0e0f20a · 2026-07-08 · sha256 c77a1798b35420f8

terraform-module-design git:20260708.0e0f20aA

Immutable. This exact content never changes and is served at /api/v1/blob/c77a1798b35420f8.

---
name: terraform-module-design
description: Designing reusable Terraform modules as composable building blocks — interface conventions, file layout, versioning, composition roots, validation, and testing — so software engineers can pick and wire blocks instead of designing infrastructure.
---

# Terraform Module Design (Reusable Building Blocks)

## Scope

Apply this skill whenever creating or reviewing a Terraform module intended for reuse, or a composition root that consumes such modules. The goal is a **module catalog**: each module owns one capability, exposes a small documented interface, and composes with the others — a developer deploying a backend service picks `docker-host` + `postgres`, not a bespoke design.

## One module, one capability

A module is a building block, not a bundle. Good module boundaries:

- `network` — VCN/VPC, subnets, gateways, base security rules
- `docker-host` — a VM that runs containers (compute + cloud-init + reserved IP + volume)
- `postgres` / `autonomous-db` — one database, its storage, its backup policy
- `secrets` — vault/secret container + the secrets an app consumes
- `dns-record`, `object-bucket`, `registry`, `load-balancer` — one each

If a module description needs "and", split it. Cross-capability wiring (the app host reading the DB endpoint) happens in the **composition root** via outputs → variables, never by one module reaching into another's resources.

## Standard layout

```
modules/<name>/
├── main.tf          → resources (split into logical files if >~150 lines: compute.tf, network.tf …)
├── variables.tf     → the input contract
├── outputs.tf       → the output contract
├── versions.tf      → terraform + provider version constraints (required_providers, NO provider blocks)
├── README.md        → what it provisions, a copy-pasteable usage example, inputs/outputs table
└── examples/basic/  → a minimal root that actually plans — doubles as documentation and test fixture
```

Composition roots (per project × environment) are thin: backend + provider config, `module` calls with pinned versions, locals for naming. See the `terraform-environments` skill for their layout.

## Interface rules

**Variables — the smaller the surface, the better the block:**
- Few required variables (the genuinely per-consumer facts: name, compartment/VPC id, image). Everything else has a **safe, free-tier-friendly default**.
- Every variable has `description` and `type` (use `object()` types for cohesive groups rather than 10 loose scalars).
- Use `validation` blocks for values with known-bad shapes (CIDRs, shape names, retention days) — fail at plan, not at apply.
- Secrets are `sensitive = true` and preferably not passed at all — pass a secret **OCID/ARN reference** and let the consumer fetch it (see `cloud-accessory-services`).
- No variable that switches the module into "a different module" (`create_database = true` inside a compute module) — that's a missing block.

**Outputs — the contract downstream blocks consume:**
- Output every attribute a sibling block plausibly needs: ids, endpoints, security-group/NSG ids, connection hosts. Missing outputs force consumers to fork the module.
- Mark outputs derived from secrets `sensitive = true`.
- Names describe the thing, not the resource type: `db_endpoint`, not `oci_database_autonomous_database_connection_strings_0`.

**Providers:**
- Reusable modules declare `required_providers` with a **version constraint** (`~>`) and never contain `provider` blocks — provider config (region, auth, aliases) belongs to the root. This is what keeps a module usable across environments and accounts.

## Versioning & distribution

- Modules live in a dedicated git repo (or `modules/` in a mono-repo) and are consumed by **git source with a pinned ref**:
  ```hcl
  module "db" {
    source = "git::https://github.com/<owner>/<modules-repo>.git//modules/postgres?ref=v1.2.0"
  }
  ```
- Tag releases semver: **MAJOR** = breaking interface change (variable removed/renamed, output removed, resource replacement), **MINOR** = new optional variable/output/capability, **PATCH** = fix with no interface change.
- A change that causes Terraform to **replace a stateful resource** is breaking regardless of the HCL diff — call it out in the release notes with the migration path (usually `moved` blocks or state `mv`).
- Environments pin different refs — that's how a module change promotes dev → prod (see `terraform-environments`).

## Naming & tagging

- Consistent resource naming from a single local: `"${var.name_prefix}-<capability>"`; the prefix carries project + environment.
- Every module applies a common tag/freeform-tag map (`project`, `environment`, `managed-by = terraform`, `module`) via a merged `var.tags` — cost attribution and orphan hunting depend on it.

## Validation & testing (in order, cheapest first)

1. `terraform fmt -check` and `terraform validate` — on every change.
2. `tflint` with the provider ruleset — catches invalid shapes/regions that validate misses.
3. `terraform plan` against `examples/basic` — the example root is the module's smoke test; keep it planning with placeholder-but-shaped values.
4. Native `terraform test` (`tests/*.tftest.hcl`) for modules with real logic — variable validation, conditionals, count/for_each arithmetic. Prefer `command = plan` assertions; reserve `apply`-mode tests for a disposable sandbox compartment.

A module whose example has never planned is a draft, not a deliverable.

## Anti-patterns

- **Über-module**: one module, 40 resources, conditional everything → split into blocks.
- **Pass-through sprawl**: root re-exposes every module variable "just in case" → the root's job is to *decide*, not forward.
- **Fork-per-project**: copying a module to tweak one default → add the optional variable upstream and pin the new minor version.
- **Unpinned sources**: `ref=main` in production roots → every apply is a lottery.
- **Provider config in modules** → module can't be reused across accounts/regions.
- **Outputs hoarding**: exposing nothing but the id → consumers `data`-lookup everything and couple to internals anyway.