Terraform / OpenTofu — baseline way of working
Inspired by Anton Babenko's terraform-skill and the patterns at terraform-best-practices.com. This is the generic base; organisation-specific overlays live in separate skills.
When to use this skill
Use when:
- Creating or reviewing a Terraform/OpenTofu module.
- Choosing between testing approaches (
validate, plan tests, native terraform test, Terratest).
- Structuring multi-environment deployments.
- Setting up CI/CD for IaC.
- Reviewing or refactoring existing configs.
- Deciding between module patterns or state-management approaches.
Don't use for:
- Basic HCL syntax questions.
- Provider-specific API reference (link to upstream docs).
Reference files
Detailed guidance lives in references/:
module-patterns.md — module hierarchy, layout, variable/output design, tag patterns, anti-patterns.
code-patterns.md — block ordering, count vs for_each, optional(…), moved {}, version management.
testing.md — the decision matrix across terraform validate, plan tests, native terraform test, and Terratest.
ci-cd.md — GitHub Actions shape, conventional commits, release-drafter, pre-commit.
security-compliance.md — checkov/tfsec/trivy, secrets hygiene, state security.
quick-reference.md — cheat sheets, decision flowchart, troubleshooting.
Core principles
1. Module hierarchy
| Level |
Scope |
Example |
| Resource module |
One logical group of connected resources |
VPC + subnets; Security group + rules |
| Infrastructure module |
A set of resource modules for one purpose in one region/account |
Landing zone in one account |
| Composition |
A complete deployment, potentially multi-region/account |
Whole org |
Build bottom-up: resource → resource module → infrastructure module → composition. Never skip a level by inlining across levels.
2. Repository layout for a resource module
<root>/
├── main.tf
├── variables.tf # every variable with type + description
├── outputs.tf # every output with description
├── terraform.tf # required_version + required_providers
├── locals.tf # only if non-empty
├── data.tf # only if non-empty
├── README.md # prose + auto-injected terraform-docs
├── CHANGELOG.md
├── LICENSE
├── .github/workflows/ # CI
├── .pre-commit-config.yaml
├── examples/ # one subdir per scenario, each runnable
│ ├── default/
│ └── <feature>/
└── tests/ # native terraform test
├── main.tftest.hcl
└── setup/ # optional fixture module
3. Version pinning
terraform {
required_version = ">= 1.9" # a reasonable modern floor, no upper bound
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.0" # floor only for AWS; some providers need an upper bound
}
}
}
required_version floor-only with >=. No upper bound on Terraform. The exact floor is not important — focus on the constraint shape.
- Provider
version — floor-only for most providers (no upper bound). Some providers need an upper bound at the next major. Never = X.Y.Z (exact) in a reusable module.
- Patch-tight (
~> X.Y.Z) is almost always wrong — it blocks security patches.
4. Variables
variable "name" {
type = string
description = "The name of the resource. Conflicts with `name_prefix`."
default = null
}
variable "mode" {
type = string
default = null
description = "The operating mode."
validation {
condition = var.mode == null || contains(["A", "B"], var.mode)
error_message = "mode must be one of A or B."
}
}
Rules:
- Every variable has
type + description.
- Internal arg order:
type → default → description → nullable → sensitive → validation.
- Prefer complex typed objects with
optional(field, default) over flat variable sprawl.
- Use
validation for enumerated values, regex constraints, cross-field invariants.
sensitive = true on secrets.
nullable = false when null is not a valid caller value.
- Use
default = null for "unset", not "".
- Snake_case names. Booleans read as statements of state (
versioning, not enable_versioning).
- No redundant prefixes (
arn, not bucket_arn).
5. Outputs
- Every output has a
description.
- First outputs:
id, arn, name — whatever maps to downstream caller references.
- Mark sensitive outputs
sensitive = true.
- Never return whole resources.
output "resource" { value = aws_x.this } leaks every attribute, including sensitive ones. Curate.
- Minimum useful surface — not every attribute re-exported.
6. Tags (cloud providers that support them)
resource "aws_s3_bucket" "default" {
# ...
tags = var.tags
}
- Expose
variable "tags" as map(string) with default = {}.
- Reference
var.tags on every taggable resource. No local.tags indirection unless the module genuinely needs to inject extra tags.
- Drop
try(var.tags, {}) noise — var.tags has a default, try() around it hides type errors.
7. Testing
Use the layered strategy in references/testing.md:
- Pre-commit:
terraform fmt, tflint, terraform validate, terraform_docs, checkov/tfsec/trivy.
- CI static: same tools, plus per-example
terraform init && terraform validate.
- Native
terraform test: mock_provider, run blocks, assert/expect_failures. Free of cloud credentials, fast.
- Terratest (Go): only when you need real apply against a real cloud.
Prefer native tests unless you actually need to hit a cloud. Keep tests under tests/.
8. CI/CD
At minimum:
- PR validation (title format, labels).
- Terraform validation (fmt + lint + validate on every example +
terraform test).
- Docs injection (
terraform-docs into README).
- Security scan (checkov).
- Release automation (release-drafter + CHANGELOG).
See references/ci-cd.md for GitHub Actions shape.
9. Security and state
- Run checkov in pre-commit + CI. Skip rules only with explicit rationale in comments.
- Never commit provider credentials,
.terraform/, lock files for root modules where they could leak.
- State: remote backend with locking. Encrypt at rest. Never share state across environments.
- Secrets: caller passes ARNs/IDs of secret managers; modules don't take plaintext secrets. If they must, mark
sensitive = true.
10. Release flow
- Conventional-commit PR titles:
feat, fix, breaking, docs, chore.
- Labels drive version bump:
breaking → major; feat/enhancement → minor; fix/chore/docs → patch.
release-drafter maintains a draft release note on every merge; publish when ready.
- Add
UPGRADING.md entries for breaking changes.
Module anti-patterns
Things that bite teams repeatedly:
- Whole-resource outputs that leak sensitive state.
- Hard-pinned child-module refs (
source = "…?ref=vX.Y.Z") — propagate version debt.
- Two sources of truth for the same input (a flat
var.name AND a nested var.obj.name).
try(var.tags) cargo-cult around variables that already have a default.
default = null on required inputs — contradicts the description.
- Empty
outputs.tf — module is a black box.
- Missing
sensitive = true on passwords/keys/tokens.
- Floating
:latest / :main image/version defaults — non-idempotent.
- Dead variables / locals / files.
- File-naming drift (camelCase,
backend.tf/module.tf/versions.tf in place of canonical names).
- Nested ternaries where
optional(field, default) would do it.
null_resource / deprecated resources (aws_s3_bucket_object, azurerm_function_app).
Decision quick-reference
- Alphabetical or grouped variable order? Either, but pick one per repo. CI's
terraform-docs --sort-by required normalises the rendered README regardless.
count vs for_each? for_each on maps/sets for stable keys. count only when the multiplicity is truly positional.
locals.tf or inline locals {}? Separate file when you have >~3 locals or when locals cross domains.
- Providers in the module or in the caller? In the caller. Modules use
configuration_aliases only when they genuinely need multi-provider orchestration (e.g. cross-account S3 replication).
Further reading
references/module-patterns.md
references/code-patterns.md
references/testing.md
references/ci-cd.md
references/security-compliance.md
references/quick-reference.md
Source: schubergphilis/agents.md — distributed by TomeVault.
1---2name: terraform-223description: Generic Terraform / OpenTofu guidance — module structure, variable + output design, block ordering, version pinning, native `terraform test`, CI/CD, security scanning, and state hygiene. Use when authoring or reviewing any Terraform/OpenTofu module, making IaC architecture decisions, picking a testing approach, setting up pipelines, or debugging state. For Schuberg Philis MCAF modules, the MCAF-specific overlays live in the `mcaf-module` and `review-mcaf` skills — use this one for baseline rules that apply regardless of organisation. Use when this capability is needed.4---56# Terraform / OpenTofu — baseline way of working78Inspired by Anton Babenko's [`terraform-skill`](https://github.com/antonbabenko/terraform-skill) and the patterns at terraform-best-practices.com. This is the generic base; organisation-specific overlays live in separate skills.910## When to use this skill1112Use when:1314- Creating or reviewing a Terraform/OpenTofu module.15- Choosing between testing approaches (`validate`, plan tests, native `terraform test`, Terratest).16- Structuring multi-environment deployments.17- Setting up CI/CD for IaC.18- Reviewing or refactoring existing configs.19- Deciding between module patterns or state-management approaches.2021Don't use for:2223- Basic HCL syntax questions.24- Provider-specific API reference (link to upstream docs).2526## Reference files2728Detailed guidance lives in `references/`:2930- [`module-patterns.md`](references/module-patterns.md) — module hierarchy, layout, variable/output design, tag patterns, anti-patterns.31- [`code-patterns.md`](references/code-patterns.md) — block ordering, `count` vs `for_each`, `optional(…)`, `moved {}`, version management.32- [`testing.md`](references/testing.md) — the decision matrix across `terraform validate`, plan tests, native `terraform test`, and Terratest.33- [`ci-cd.md`](references/ci-cd.md) — GitHub Actions shape, conventional commits, release-drafter, pre-commit.34- [`security-compliance.md`](references/security-compliance.md) — checkov/tfsec/trivy, secrets hygiene, state security.35- [`quick-reference.md`](references/quick-reference.md) — cheat sheets, decision flowchart, troubleshooting.3637## Core principles3839### 1. Module hierarchy4041| Level | Scope | Example |42|---|---|---|43| **Resource module** | One logical group of connected resources | VPC + subnets; Security group + rules |44| **Infrastructure module** | A set of resource modules for one purpose in one region/account | Landing zone in one account |45| **Composition** | A complete deployment, potentially multi-region/account | Whole org |4647Build bottom-up: resource → resource module → infrastructure module → composition. Never skip a level by inlining across levels.4849### 2. Repository layout for a resource module5051```52<root>/53├── main.tf54├── variables.tf # every variable with type + description55├── outputs.tf # every output with description56├── terraform.tf # required_version + required_providers57├── locals.tf # only if non-empty58├── data.tf # only if non-empty59├── README.md # prose + auto-injected terraform-docs60├── CHANGELOG.md61├── LICENSE62├── .github/workflows/ # CI63├── .pre-commit-config.yaml64├── examples/ # one subdir per scenario, each runnable65│ ├── default/66│ └── <feature>/67└── tests/ # native terraform test68 ├── main.tftest.hcl69 └── setup/ # optional fixture module70```7172### 3. Version pinning7374```hcl75terraform {76 required_version = ">= 1.9" # a reasonable modern floor, no upper bound7778 required_providers {79 aws = {80 source = "hashicorp/aws"81 version = ">= 6.0" # floor only for AWS; some providers need an upper bound82 }83 }84}85```8687- `required_version` floor-only with `>=`. No upper bound on Terraform. The exact floor is not important — focus on the constraint shape.88- Provider `version` — floor-only for most providers (no upper bound). Some providers need an upper bound at the next major. Never `= X.Y.Z` (exact) in a reusable module.89- Patch-tight (`~> X.Y.Z`) is almost always wrong — it blocks security patches.9091### 4. Variables9293```hcl94variable "name" {95 type = string96 description = "The name of the resource. Conflicts with `name_prefix`."97 default = null98}99100variable "mode" {101 type = string102 default = null103 description = "The operating mode."104105 validation {106 condition = var.mode == null || contains(["A", "B"], var.mode)107 error_message = "mode must be one of A or B."108 }109}110```111112Rules:113114- Every variable has `type` + `description`.115- Internal arg order: `type` → `default` → `description` → `nullable` → `sensitive` → `validation`.116- Prefer complex typed objects with `optional(field, default)` over flat variable sprawl.117- Use `validation` for enumerated values, regex constraints, cross-field invariants.118- `sensitive = true` on secrets.119- `nullable = false` when `null` is not a valid caller value.120- Use `default = null` for "unset", not `""`.121- Snake_case names. Booleans read as statements of state (`versioning`, not `enable_versioning`).122- No redundant prefixes (`arn`, not `bucket_arn`).123124### 5. Outputs125126- Every output has a `description`.127- First outputs: `id`, `arn`, `name` — whatever maps to downstream caller references.128- Mark sensitive outputs `sensitive = true`.129- **Never return whole resources.** `output "resource" { value = aws_x.this }` leaks every attribute, including sensitive ones. Curate.130- Minimum useful surface — not every attribute re-exported.131132### 6. Tags (cloud providers that support them)133134```hcl135resource "aws_s3_bucket" "default" {136 # ...137 tags = var.tags138}139```140141- Expose `variable "tags"` as `map(string)` with `default = {}`.142- Reference `var.tags` on every taggable resource. No `local.tags` indirection unless the module genuinely needs to inject extra tags.143- Drop `try(var.tags, {})` noise — `var.tags` has a default, `try()` around it hides type errors.144145### 7. Testing146147Use the layered strategy in [`references/testing.md`](references/testing.md):1481491. **Pre-commit**: `terraform fmt`, `tflint`, `terraform validate`, `terraform_docs`, `checkov`/`tfsec`/`trivy`.1502. **CI static**: same tools, plus per-example `terraform init && terraform validate`.1513. **Native `terraform test`**: `mock_provider`, `run` blocks, `assert`/`expect_failures`. Free of cloud credentials, fast.1524. **Terratest** (Go): only when you need real apply against a real cloud.153154Prefer native tests unless you actually need to hit a cloud. Keep tests under `tests/`.155156### 8. CI/CD157158At minimum:159160- PR validation (title format, labels).161- Terraform validation (fmt + lint + validate on every example + `terraform test`).162- Docs injection (`terraform-docs` into README).163- Security scan (checkov).164- Release automation (release-drafter + CHANGELOG).165166See [`references/ci-cd.md`](references/ci-cd.md) for GitHub Actions shape.167168### 9. Security and state169170- Run checkov in pre-commit + CI. Skip rules only with explicit rationale in comments.171- Never commit provider credentials, `.terraform/`, lock files for root modules where they could leak.172- State: remote backend with locking. Encrypt at rest. Never share state across environments.173- Secrets: caller passes ARNs/IDs of secret managers; modules don't take plaintext secrets. If they must, mark `sensitive = true`.174175### 10. Release flow176177- Conventional-commit PR titles: `feat`, `fix`, `breaking`, `docs`, `chore`.178- Labels drive version bump: `breaking` → major; `feat`/`enhancement` → minor; `fix`/`chore`/`docs` → patch.179- `release-drafter` maintains a draft release note on every merge; publish when ready.180- Add `UPGRADING.md` entries for breaking changes.181182## Module anti-patterns183184Things that bite teams repeatedly:185186- Whole-resource outputs that leak sensitive state.187- Hard-pinned child-module refs (`source = "…?ref=vX.Y.Z"`) — propagate version debt.188- Two sources of truth for the same input (a flat `var.name` AND a nested `var.obj.name`).189- `try(var.tags)` cargo-cult around variables that already have a default.190- `default = null` on required inputs — contradicts the description.191- Empty `outputs.tf` — module is a black box.192- Missing `sensitive = true` on passwords/keys/tokens.193- Floating `:latest` / `:main` image/version defaults — non-idempotent.194- Dead variables / locals / files.195- File-naming drift (camelCase, `backend.tf`/`module.tf`/`versions.tf` in place of canonical names).196- Nested ternaries where `optional(field, default)` would do it.197- `null_resource` / deprecated resources (`aws_s3_bucket_object`, `azurerm_function_app`).198199## Decision quick-reference200201- **Alphabetical or grouped variable order?** Either, but pick one per repo. CI's `terraform-docs --sort-by required` normalises the rendered README regardless.202- **`count` vs `for_each`?** `for_each` on maps/sets for stable keys. `count` only when the multiplicity is truly positional.203- **`locals.tf` or inline `locals {}`?** Separate file when you have >~3 locals or when locals cross domains.204- **Providers in the module or in the caller?** In the caller. Modules use `configuration_aliases` only when they genuinely need multi-provider orchestration (e.g. cross-account S3 replication).205206## Further reading207208- `references/module-patterns.md`209- `references/code-patterns.md`210- `references/testing.md`211- `references/ci-cd.md`212- `references/security-compliance.md`213- `references/quick-reference.md`214215---216> Source: [schubergphilis/agents.md](https://github.com/schubergphilis/agents.md) — distributed by [TomeVault](https://tomevault.io).217<!-- tomevault:4.0:skill_md:2026-05-22 -->