Terraform Module Developer
The strict 8-file pattern: 4 root files (main.tf + variables.tf + outputs.tf + versions.tf) + 4 wrapper files (wrappers/*.tf) + a polished README.md with terraform-docs auto-injection. Multi-provider; works for any Terraform/OpenTofu provider.
When to use
- The user asks for a new Terraform module, IaC module,
.tfresource, or a wrapper module. - The user wants to harden, refactor, or review an existing module.
- A polyglot infra repo needs consistent module structure across services / regions / providers.
- The user wants
terraform-docswired up so READMEs stay in sync with code. - The user wants
tflint/tfsec/checkovrunning pre-commit and in CI.
Skip this skill for: cloud-vendor-specific business logic (use a vendor SDK skill), application code (different language skills), or non-IaC config like Helm charts (use gitops-cd-developer).
Required structure
Every module is exactly this — no fewer files, no extra files, this layout:
modules/<service>/<resource>/
├── main.tf # resource definition, count = var.<prefix>_create ? 1 : 0
├── variables.tf # all vars prefixed `<prefix>_*`, heredoc descriptions
├── outputs.tf # try/element/concat pattern for every output
├── versions.tf # required_version + required_providers (pinned)
├── README.md # static intro + BEGIN_TF_DOCS / END_TF_DOCS markers
└── wrappers/
├── main.tf # module "wrapper" { for_each = var.items, ... }
├── variables.tf # `defaults` (any) + `items` (any)
├── outputs.tf # output "wrapper" { value = module.wrapper }
└── versions.tf # same constraints as root
Total: 2 directories, 9 files (8 .tf + 1 README.md). Validators check this exactly.
Plus, repo-root tooling (one-time setup):
.terraform-docs.yml— config that injects between the README markers..tflint.hcl— linting rules + provider plugins..pre-commit-config.yaml(or fragment) — wires terraform-fmt, terraform-docs, tflint, tfsec into commit hooks.
Workflow
- Confirm the provider. Default: ask the user which provider (
aws,azurerm,google,huaweicloud,oci,digitalocean,cloudflare, etc.) and pin a version. Save provider preference to memory after first run. - Confirm the service + resource.
<service>is the namespace (e.g.,vpc,compute,storage).<resource>is the specific resource type (e.g.,subnet,instance,bucket). Variable prefix derived as<service>_<resource>with hyphens turned into underscores. - Scaffold the structure. Run
bash scripts/scaffold-module.sh <service> <resource> <provider> <provider-version>. Creates the 9 files fromassets/templates/terraform/. Seereferences/module-anatomy.mdfor the file-by-file breakdown. - Fetch provider docs. Open the resource page on the Terraform Registry. Capture the full set of arguments, optional fields, dynamic blocks, and exported attributes. The heredoc descriptions in
variables.tfshould mirror the registry exactly. - Fill in
main.tf. Map provider arguments to module variables. Use the count conditional. Applyreferences/dynamic-blocks.mdfor any nested blocks. - Fill in
variables.tf. Applyreferences/variable-patterns.md— naming, heredoc, complex types withoptional(), the<prefix>_timeoutsobject pattern when the resource supports timeouts. - Fill in
outputs.tf. Applyreferences/output-patterns.md—try(element(concat(<resource>.this.*.<attr>, [<default>]), 0), <fallback>)for every export. - Fill in
wrappers/. Applyreferences/wrapper-pattern.md— every root variable gets a correspondingtry(each.value.<x>, var.defaults.<x>, <default>)line inwrappers/main.tf. - Wire terraform-docs. Apply
references/readme-and-terraform-docs.md— confirm.terraform-docs.ymlexists at repo root, README has theBEGIN_TF_DOCS/END_TF_DOCSmarkers, and the pre-commit hook is registered. - Wire security scanning + tests. Apply
references/security-scanning.md(tflint + tfsec/trivy + checkov) andreferences/module-testing.md(terraform test framework + examples/ directory). - Validate. Run
bash scripts/validate-module.sh <module-path>— checks file count (2 dirs / 9 files), nolist(any), prefix consistency, iterator patterns, README markers. Aim for 100%. - Format + native validate.
terraform fmt -recursive && terraform validatefrom the module directory. - Generate docs.
terraform-docs -c .terraform-docs.yml <module-path>— confirm the README inputs/outputs tables fill in correctly.
Available resources
references/module-anatomy.md— 8-file structure, four-file pattern, naming conventions, count conditional, file-by-file breakdown.references/variable-patterns.md— naming convention, heredoc descriptions, complex object types withoptional(),<prefix>_timeoutsobject pattern.references/output-patterns.md—try(element(concat(...), 0), <fallback>)pattern with examples for string / list / map / object outputs.references/dynamic-blocks.md— iterator pattern (iterator = <name>_cfg), nested dynamic blocks,can(length())guard.references/wrapper-pattern.md—for_each = var.items,defaults+itemsvariables,try(each.value, var.defaults, <fallback>)chain, type-safe wrapper variables for timeouts.references/readme-and-terraform-docs.md— README template structure,BEGIN_TF_DOCS/END_TF_DOCSmarkers,.terraform-docs.ymlconfig (mode: inject,formatter: markdown table), pre-commit hook integration, CI integration.references/provider-conventions.md— multi-provider notes (AWS / Azure / GCP / HuaweiCloud / OCI / DigitalOcean / Cloudflare): naming differences, region handling, tagging conventions, provider-specific gotchas.references/security-scanning.md— tflint config + plugins, tfsec / trivy security scanning, checkov compliance scanning, integrating all three into pre-commit and CI.references/module-testing.md— Terraform 1.6+ native test framework (*.tftest.hcl),examples/directory pattern, integration tests, contract tests.references/anti-patterns.md— 12 named anti-patterns with the better alternative.assets/templates/terraform/{main,variables,outputs,versions}.tf.tmpl+README.md.tmpl— root module starting points (renamed.tmplso IDE Terraform plugins don't try to parse the placeholder tokens).assets/templates/terraform/wrappers-{main,variables,outputs,versions}.tf.tmpl— wrapper module starting points.assets/templates/configs/{terraform-docs.yml,tflint.hcl,pre-commit-fragment.yaml,editorconfig}— repo-root tooling configs.assets/examples/{simple-module,dynamic-blocks,multi-block,module-test}.md— full worked examples (network / storage / firewall / test).scripts/scaffold-module.sh— generic, multi-provider scaffolder. Run it instead of writing 9 files by hand.scripts/validate-module.sh— score a module against the checklist. Run after editing.scripts/setup-tooling.sh— install terraform-docs, tflint, tfsec, checkov via standard package managers.
Top gotchas (always inline — do not skip)
- 8 files, 2 directories. No fewer, no extras. A module without
wrappers/is incomplete. A module with extra.tffiles (e.g.,data.tf,locals.tf) is the wrong shape — fold them intomain.tf. - Variable prefix is
<service>_<resource>_*. Always.vpc_subnet_name, notname. Prefix-less variables fight every consumer that imports more than one of your modules. - Dynamic blocks REQUIRE
iterator = <name>_cfg. Without the iterator, the block name shadows the variable. With it, you get a clean<name>_cfg.value.fieldreference. - Output pattern is non-negotiable:
try(element(concat(<resource>.this.*.<attr>, [<default>]), 0), <fallback>). Handles count = 0, null attrs, and missing fields in one expression. list(any)is banned. Always uselist(object({...}))withoptional()for optional fields.list(any)loses type safety and breaks consumer-side validation.- Timeouts use ONE object variable, not three.
<prefix>_timeoutsof typeobject({create=optional, update=optional, delete=optional}). Never<prefix>_timeout_create/_update/_deleteas separate vars. - Wrapper variables:
defaults+itemsonly, both strictly typed.defaultsisobject({...}),itemsismap(object({...})). Every field isoptional(<type>)and mirrors a root variable — same name, same type, same nested shape.type = anyis the old shortcut; typed wrappers are the contract because misspelled fields and wrong types fail at plan time instead of silently being ignored. - README has BEGIN_TF_DOCS / END_TF_DOCS markers. terraform-docs runs in
mode: injectand replaces only the content between markers. The static portion (Usage, Examples, Notes, Provider Documentation link) lives outside the markers. .terraform-docs.ymllives at repo root. Not per-module. It applies to every module viaterraform-docs -c .terraform-docs.yml <path>.- Pin every
required_providersversion. Lower bound minimum (>= 1.91.0). Floating refs (~> 1,latest) break reproducibility across team members and CI. enterprise_project_idand similar provider-specific fields are alwaysoptional. Don't make them required even when the provider treats them as semi-required — different organizations have different setups.- Module-level region defaults to
null, not a hardcoded value. Let the provider-level region win when the module caller doesn't override. Hardcoded defaults ("us-east-1","westus2", or any other concrete region) leak organizational choices into reusable modules.
What you DO
- Always start from
scripts/scaffold-module.sh. Never hand-write the 9 files. - Use the variable prefix
<service>_<resource>_*consistently. - Use
count = var.<prefix>_create ? 1 : 0on the resource block. - Write heredoc descriptions on every variable that mirror the provider registry.
- Use
list(object({...}))withoptional()for any list-of-records variable. - Use the
<prefix>_timeoutsobject variable (not three separate vars) when the resource supports timeouts. - Use the iterator pattern (
iterator = <name>_cfg) on every dynamic block. - Use the
try(element(concat(...), 0), <fallback>)pattern for every output. - Write a
wrappers/directory with typeddefaults(object({...})) anditems(map(object({...}))), mirroring every root variable asoptional(<type>). Thread per-resource values via thetry(each.value, var.defaults, <fallback>)chain inwrappers/main.tf. - Place
BEGIN_TF_DOCS/END_TF_DOCSmarkers in every README between Usage and Notes. - Wire terraform-docs as a pre-commit hook (and a CI check).
- Wire tflint + tfsec/trivy + checkov as pre-commit + CI.
- Add an
examples/subdirectory with a working basic example, thentests/*.tftest.hclfor the native test framework. - Run
scripts/validate-module.shafter every edit; aim for 100%. - Run
terraform fmt -recursive && terraform validatebefore declaring done.
What you do NOT do
- Skip the
wrappers/directory. - Use
list(any)ormap(any)anywhere — the wrapper'sdefaultsanditemsare also strictly typed (object({...})/map(object({...}))). - Hand-write per-resource wrapper variables (only
defaults+items). - Hardcode regions, project IDs, or organizational defaults.
- Use
~>or unpinned floating refs for provider versions. - Place
data.tf/locals.tfas separate files (fold intomain.tf). - Forget the
iterator = <name>_cfgon a dynamic block. - Use
for_each = var.x != null ? var.x : [](usecan(length(var.x)) ? var.x : []). - Write the inputs / outputs / providers tables manually in README — let terraform-docs inject them.
- Commit a module without running
terraform fmt. - Create a module without an
examples/working example. - Mix
countandfor_eachon the same resource — pick one. - Reference provider-specific resources without including the provider in
required_providers. - Make tags optional with no
merge()for theNametag (every module merges aNametag from<prefix>_name).
Source: MKAbuMattar/skills — distributed by TomeVault.