A reusable Terraform module library for the officialdad infrastructure. Each top-level
directory is a component; the actual Terraform lives in that component's terraform/
subdirectory.
This repo is public so the environments repos can fetch modules over HTTPS with no auth. Modules are consumed via a pinned git source, e.g.:
terraform {
source = "git::https://github.com/officialdad/infra-components.git//<component>/terraform?ref=<tag>"
}The //<component>/terraform part selects the subdirectory inside this repo, and ?ref=<tag>
pins a version.
This README is also the conventions contract for the officialdad IaC repos: the anatomy,
the global object, naming, and release process below are shared by all three repos. See
CHANGELOG.md for what changed in each tagged version, and CLAUDE.md
for how agents work in this repo (the automated quality gate, design principles, and the
component checklist).
| Repo | Role | Module ref |
|---|---|---|
infra-components |
Reusable Terraform modules | tagged releases |
infra-environments-dev |
Dev environment (ungated) | tracks main |
infra-environments-prod |
Prod environment (gated) | pinned tags |
Clouds: both AWS (
hashicorp/aws) and GCP (hashicorp/google) modules are kept here, so an environment can pick either stack. See CHANGELOG.md for the history.
| Component | Cloud | Purpose | Key outputs |
|---|---|---|---|
vpc |
AWS | Network foundation — wraps terraform-aws-modules/vpc (VPC + per-AZ subnets + NAT) |
vpc_id, private_subnet_ids, public_subnet_ids |
ec2 |
AWS | One or more EC2 instances (instances map, bootstrap-agnostic) via the ec2-instance + security-group modules; SSM access, no public IP, per-instance named ingress_rules |
instances (map keyed by instance key) |
iam-policy |
AWS | Generic IAM policy factory — wraps caller-composed JSON documents into named, tagged managed policies (feeds ec2 iam_role_policy_arns) |
policy_arns (map keyed by policy key) |
ebs-volume |
AWS | Standalone encrypted EBS data volumes (volumes map) in their own state — decoupled from the ec2 instance lifecycle so data survives a compute destroy/apply |
volumes (map keyed by volume key) |
network |
GCP | Network foundation — custom-mode VPC + regional subnet + Cloud NAT + IAP-SSH firewall (wraps Google Cloud Foundation Toolkit) | network_name, subnetwork_self_link, ssh_tag |
compute-engine |
GCP | One or more Compute Engine VMs (instances map, bootstrap-agnostic); OS Login + IAP access, no external IP |
instances (map keyed by instance key) |
github |
GitHub | GitHub repositories as code (repo factory) | repository_names, repository_urls |
automation-roles |
AWS | CI identity — GitHub Actions OIDC provider + the least-privilege IAM role the pipeline assumes (no static keys) | role_arn, oidc_provider_arn |
The components form two parallel dependency chains, one per cloud:
vpc → ec2 (AWS) and network → compute-engine (GCP) — in each, instances launch into
the network the foundation component outputs. github and automation-roles are standalone
(no network); automation-roles is a human-applied CI bootstrap, kept out of its own pipeline.
Each component is a directory with a terraform/ subdir:
<component>/
├── README.md # from .github/component-readme-template.md; Inputs/Outputs generated
└── terraform/
├── versions.tf # required_version (min floor, >= 1.5.7) + required_providers (pinned ~> ranges)
├── variables.tf # inputs; first variable is always `global` (except `github`, see below)
├── main.tf # provider + resources
└── outputs.tf # values consumed by downstream components
Component READMEs follow one house style on a shared skeleton: prose sections (What it creates,
Auth, Dependencies) plus an Inputs/Outputs table generated by
terraform-docs between <!-- BEGIN_TF_DOCS --> markers — never
hand-edited. Scaffold new ones from
.github/component-readme-template.md; the style and table
generation are enforced by pre-commit + CI (see
CLAUDE.md → Component README style).
Every component takes a global object as its first variable, carrying environment-wide context
(name, region, tags) so modules stay generic. The environments repo passes it once via a shared
global.tfvars.
variable "global" {
type = object({
environment_name = string
deploy_region = string
tags = map(string)
})
}Use it for naming and tags: "${var.global.environment_name}-vpc", and
merge(var.global.tags, { ... }) on every resource. On GCP, global.tags is sanitized into
resource labels where the provider supports them (e.g. the compute-engine instance); GCP
networking resources can't be labeled, so only naming carries through there.
One exception:
githubtakes noglobal. Its resources are org-scoped, not environment-scoped, andgithub_repositoryhas nothing to tag — aglobalinput would be a dead declaration (whichtflint's recommended preset flags). Every other component takesglobalas its first variable.
Deterministic and readable — no random suffixes (we're single-region, two environments; we don't need global-uniqueness hashing).
- Resources:
<environment_name>-<component>[-<purpose>]e.g.dev-vpc,prod-app-sg,prod-app-alb-tg. - State buckets:
tfstate-officialdad-<env>-<region-or-CHANGEME>(one bucket per environment, separate AWS accounts for dev vs prod). - State keys: one per component, set automatically by Terragrunt via
${path_relative_to_include()}/terraform.tfstate. - Tags: always include
Environment,ManagedBy = "terraform", plusvar.global.tags.
Modules are versioned with git tags (vMAJOR.MINOR.PATCH), consumed via ?ref=<tag>. Dev can
track a branch (?ref=main) while iterating; prod pins a tag.
- MAJOR — breaking input/output change (callers must edit their config). Call it out loudly in CHANGELOG.
- MINOR — new feature, backward compatible.
- PATCH — bug fix, no interface change.
Tags are repo-wide, not per-component (vX.Y.Z, never vpc/vX.Y.Z). That's deliberate: each
component is consumed by its own pinned source, so a single monorepo tag serves every component
independently.
- Each pin is fetched independently. A consumer's
source = "…//vpc/terraform?ref=v0.7.0"pulls only that subtree at that tag. Components bump on their own schedule — leaving an unchanged component pinned at an older tag is valid, not drift. - The changelog says which components moved. Every entry is component-scoped (
**vpc:** …,**ec2:** …) because it's generated fromtype(scope): subjectcommits with scope = the component. DiffingCHANGELOG.mdbetween two tags (or thecompare/vA...vBlink) tells a consumer whether their component actually changed — and whether the pin is worth bumping. - Each tag ships a GitHub Release. The pushed tag publishes a GitHub Release (notes = the new
CHANGELOG section), so "latest" and its notes are machine-discoverable — a bump bot
(Renovate/Dependabot/custom) can open "bump to
vX.Y.Z" PRs against it.
Steps 3–4 (generate the CHANGELOG section, commit, tag) are automated — you pick the version and
confirm the irreversible push; the tooling does the rest. One-time per clone: install git-cliff
(see Toolchain) and run scripts/setup-hooks.sh to install
the git hooks (Claude Code runs it on session start).
-
Make the module change on a branch, open a PR, merge to
main. The commit subjects are the changelog — write themtype(scope): subjectwith scope = component, and flag input/output/ breaking changes there. CI previews the generated entries on your PR viacliff.toml. -
infra-environments-dev(tracksmain) picks it up — apply and let it soak. -
Cut the release from
main(after it has soaked):- In Claude Code:
/release X.Y.Z— previews the generated section, then pushes on your confirm. - By hand:
scripts/release.sh X.Y.Z.
Either way
scripts/release.shgenerates the new## [X.Y.Z] - <today>section from the unreleased Conventional Commits with git-cliff, splices it under## [Unreleased], fixes the two compare links, and commitschore(release): vX.Y.Z+ tagsvX.Y.Z— locally, nothing pushed. No hand-curation: the commit subjects are the entries, and history ≤v0.6.0is frozen (only the new section is generated). Review withgit show vX.Y.Z; undo withgit tag -d vX.Y.Z && git reset --hard HEAD~1. - In Claude Code:
-
Publish:
git push origin main vX.Y.Z(branch + tag atomically — avoids the tag landing on a different commit if something races). Apre-pushguard (scripts/check-release-tag.sh) refuses the push unlessCHANGELOG.mdhas the matching## [X.Y.Z] - <date>section. The pushed tag triggerschangelog.yml, which publishes the GitHub Release from the same git-cliff config — so the Release matches the CHANGELOG section. No hand-written notes. -
Promote to prod: PR in
infra-environments-prodbumping the component'sversions.hcl("vOLD"→"vX.Y.Z"), reviewed, then apply.
Conventional Commits — type(scope): subject, with an
imperative subject. type is feat / fix / docs / refactor / chore / ci / revert;
scope is the component, e.g. feat(ec2): add ami_ssm_parameter, fix(vpc): .... Mark a breaking
change with feat(ec2)!: or a BREAKING CHANGE: footer. Reference an issue if there is one.
The format is enforced on the commit-msg stage by
conventional-pre-commit — run
scripts/setup-hooks.sh once per clone (it wires the pre-commit,
commit-msg, and pre-push hooks). feat/fix/breaking line up with the PR template's Type field
and the CHANGELOG sections.
Tagging stays manual and deliberate — we do not auto-release on merge. A human cuts the version tag after a change has soaked in dev (see Versioning & releasing).
- Terraform 1.15.5, Terragrunt 1.0.7 (the environments repos pin these via
.terraform-version/.terragrunt-version). - CI (
.github/workflows/ci.yml) runsterraform fmt/validate/tflintper component — the matrix is derived from the filesystem (each<component>/terraform/dir), so new components are validated automatically with no list to maintain. - git-cliff (changelog generation) —
scripts/release.shruns it locally to generate the CHANGELOG section from Conventional Commits; CI runs the same generator viaorhun/git-cliff-action. Install it withcargo install git-cliff,brew install git-cliff, or a prebuilt binary from the releases (tested with 2.13.1).
- All values are placeholders — no real AWS/GCP account IDs, credentials, or hostnames.
- Modules are minimal but valid and applyable (real resource blocks), so you can grow them.
- A real apply requires the relevant cloud credentials (AWS or GCP) and a state backend, both configured in the env repos.