Terraform
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.From its SKILL.md
npx -y skills add schubergphilis/agents.md --skill terraformAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 8 stars8 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
9.4 KB, as published. Nobody here has run it
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, nativeterraform 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,countvsfor_each,optional(…),moved {}, version management.testing.md— the decision matrix acrossterraform validate, plan tests, nativeterraform 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_versionfloor-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
validationfor enumerated values, regex constraints, cross-field invariants. sensitive = trueon secrets.nullable = falsewhennullis not a valid caller value.- Use
default = nullfor "unset", not"". - Snake_case names. Booleans read as statements of state (
versioning, notenable_versioning). - No redundant prefixes (
arn, notbucket_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"asmap(string)withdefault = {}. - Reference
var.tagson every taggable resource. Nolocal.tagsindirection unless the module genuinely needs to inject extra tags. - Drop
try(var.tags, {})noise —var.tagshas 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,runblocks,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-docsinto 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-draftermaintains a draft release note on every merge; publish when ready.- Add
UPGRADING.mdentries 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.nameAND a nestedvar.obj.name). try(var.tags)cargo-cult around variables that already have a default.default = nullon required inputs — contradicts the description.- Empty
outputs.tf— module is a black box. - Missing
sensitive = trueon passwords/keys/tokens. - Floating
:latest/:mainimage/version defaults — non-idempotent. - Dead variables / locals / files.
- File-naming drift (camelCase,
backend.tf/module.tf/versions.tfin 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 requirednormalises the rendered README regardless. countvsfor_each?for_eachon maps/sets for stable keys.countonly when the multiplicity is truly positional.locals.tfor inlinelocals {}? 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_aliasesonly when they genuinely need multi-provider orchestration (e.g. cross-account S3 replication).
Further reading
references/module-patterns.mdreferences/code-patterns.mdreferences/testing.mdreferences/ci-cd.mdreferences/security-compliance.mdreferences/quick-reference.md