Terraform Modules skill for Codex
Small typed interfaces, refactors with zero destroys. Codex designs Terraform modules around a narrow, typed interface with optional attributes and validation, keeps provider configuration in the root module, prefers for_each so removing one item never recreates the rest, and adds moved blocks whenever an address changes. Each change is checked with fmt, validate, tflint and terraform test, then planned in every caller to prove nothing gets destroyed.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- terraform-modules Installs to ~/.agents/skills/terraform-modules/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install terraform-modules The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project terraform-modules Installs to .agents/skills/terraform-modules/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/terraform-modules.zipunzip -o terraform-modules.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) terraform-modules PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $terraform-modules, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs, refactors and publishes Terraform modules - file layout, typed variables with optional attributes and validation, minimal outputs, provider requirements without provider blocks, for_each over count, moved blocks for safe refactors, terraform test suites, tflint, terraform-docs and semantic versioning of registry or Git modules. Use when writing a new module, splitting a root module into reusable pieces, changing a module's interface, upgrading a pinned module version, or reviewing module code.
Use it when
- The same group of resources is copied across environments or services
- A module's variables or outputs need to change
- A refactor would otherwise destroy and recreate resources
- A module needs tests, docs and a release version
Use something else when
- You need to import, move or remove resources across state files: use terraform-state
- The codebase is moving from Terraform to OpenTofu: use opentofu
How to check the result
terraform validate, tflint and terraform test pass, and a plan in every calling root module shows no destroy actions that were not explicitly intended.
The mistake it prevents
Renaming a resource or switching count to for_each without a moved block, which plans a destroy and recreate of live infrastructure.
A good module has a small, typed interface that is hard to misuse, creates nothing its
caller did not ask for, never configures providers itself, refactors without destroying
resources, and ships with tests and a version. Callers read variables.tf and
outputs.tf; those two files are the API.
Inspect first
- Read
AGENTS.md, thenversions.tf/terraformblocks for the Terraform and provider versions in use (terraform version). Features below note their minimum version. - Find every caller of the module (
rg -n 'source\s*=.*modules/<name>') and the state they manage. An interface change affects all of them. - Check for existing tooling:
.tflint.hcl,tests/, pre-commit hooks, terraform-docs config. - Run
terraform init -backend=false && terraform validatein the module and a caller to establish a clean baseline.
terraform init downloads providers and modules (network). In a sandbox with network
off, use an existing .terraform directory or provider mirror, or ask for approval.
Running plan against real backends needs cloud credentials; ask the user.
When to make a module
| Situation | Decision |
|---|---|
| The same 3+ resources are repeated with small differences | Module |
| A single resource with a few arguments | No module; a thin wrapper adds indirection and no value |
| A well-maintained registry module fits (VPC, EKS) | Use it pinned, or wrap it with your defaults |
| Logic differs per caller in many places | Separate modules, not a module full of flags |
| Organisation-wide standard (tagging, encryption, logging) | Module with opinionated defaults and few knobs |
Layout
modules/s3-bucket/
├── main.tf # resources
├── variables.tf # inputs, typed and documented
├── outputs.tf # only what callers need
├── versions.tf # required_version and required_providers
├── README.md # generated by terraform-docs
├── examples/basic/ # a runnable caller
└── tests/basic.tftest.hcl
Interface
# versions.tf
terraform {
required_version = ">= 1.6"
required_providers {
aws = { source = "hashicorp/aws", version = ">= 5.0, < 7.0" }
}
}
Modules declare provider requirements with ranges; root modules pin exact versions via
.terraform.lock.hcl. Never write a provider "aws" {} block inside a reusable module:
it blocks for_each/count on the module and makes provider aliasing impossible. Accept
aliased providers from the caller with configuration_aliases when needed.
# variables.tf
variable "name" {
description = "Bucket name. Must be globally unique."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.name))
error_message = "name must be 3-63 chars of lowercase letters, digits, dots or hyphens."
}
}
variable "lifecycle_rules" {
description = "Expiration rules keyed by rule id."
type = map(object({
prefix = optional(string, "")
expiration_days = number
}))
default = {}
nullable = false
}
variable "tags" {
description = "Tags merged onto every resource."
type = map(string)
default = {}
}
- Use concrete types (
object,map(object)), neveranyfor structured input.optional(type, default)needs Terraform 1.3+. nullable = falselets callers passnulland get the default.- Validation blocks referencing other variables need 1.9+; on older versions use a
preconditionin alifecycleblock of the resource. - Mark secrets
sensitive = true. Prefer accepting an ARN or secret reference over the secret value itself. - Keep booleans for feature toggles few. Five
create_*flags usually mean two modules.
# main.tf
resource "aws_s3_bucket" "this" {
bucket = var.name
tags = var.tags
}
resource "aws_s3_bucket_lifecycle_configuration" "this" {
count = length(var.lifecycle_rules) > 0 ? 1 : 0
bucket = aws_s3_bucket.this.id
dynamic "rule" {
for_each = var.lifecycle_rules
content {
id = rule.key
status = "Enabled"
filter { prefix = rule.value.prefix }
expiration { days = rule.value.expiration_days }
}
}
}
# outputs.tf
output "bucket_arn" {
description = "ARN for IAM policies."
value = aws_s3_bucket.this.arn
}
Output identifiers callers need (IDs, ARNs, names, endpoints). Do not output whole resource objects; that leaks every attribute into the interface.
Count, for_each and naming
for_eachover a map or set of strings for collections.countreorders addresses when an item is removed from the middle, which destroys and recreates everything after it.count = condition ? 1 : 0is fine for one optional resource.- Name the primary resource
this(or a meaningful noun when there are several). for_eachkeys must be known at plan time; keys derived from resource attributes (IDs) fail with “Invalid for_each argument”. Key on input values instead.
Refactoring without destroying
Moving a resource into a module, renaming it, or switching count to for_each changes
its address. Declare the move so plan shows a move, not destroy plus create:
moved {
from = aws_s3_bucket.logs
to = module.logs_bucket.aws_s3_bucket.this
}
moved {
from = aws_instance.web[0]
to = aws_instance.web["primary"]
}
Keep moved blocks for at least one released version so every caller’s state passes
through them. Moves across state files are a $terraform-state job.
Versioning and release
- Semver tags (
v2.3.0). Major for removed or renamed variables, changed types, changed defaults that alter infrastructure, or address changes withoutmovedblocks. - Callers pin: registry
version = "~> 2.3"; Gitsource = "git::https://github.com/org/tf-modules.git//s3-bucket?ref=v2.3.0". Never point production at a branch. - Upgrading a module version in a caller: read the changelog,
terraform init -upgrade, then plan and review every change before apply.
Tests
terraform test (1.6+) with plan-only runs is cheap and needs no cloud apply:
# tests/basic.tftest.hcl
variables {
name = "acme-logs-test"
lifecycle_rules = { logs = { expiration_days = 30 } }
}
run "creates_lifecycle_rule" {
command = plan
assert {
condition = length(aws_s3_bucket_lifecycle_configuration.this) == 1
error_message = "lifecycle configuration should exist when rules are given"
}
}
run "rejects_bad_name" {
command = plan
variables { name = "Bad_Name" }
expect_failures = [var.name]
}
Plan still needs provider credentials for most providers; use mock_provider "aws" {}
(1.7+) in the test file to run fully offline. command = apply runs create real resources
and cost money: only with the user’s agreement and in a sandbox account.
Workflow
- [ ] 1. Baseline: init -backend=false, validate, plan in one caller
- [ ] 2. Design or change the interface (variables.tf, outputs.tf) first
- [ ] 3. Implement; add moved blocks for every address change
- [ ] 4. terraform fmt -recursive, validate, tflint
- [ ] 5. terraform test (plan-only or mocked)
- [ ] 6. Plan in each caller: expect no destroys unless intended
- [ ] 7. terraform-docs, changelog entry, version tag proposal
Verification
terraform fmt -check -recursive
terraform init -backend=false && terraform validate
tflint --recursive
terraform test
terraform plan -out=tfplan && terraform show -json tfplan | jq '[.resource_changes[] | select(.change.actions | index("delete"))] | length'
terraform-docs markdown table --output-file README.md .
Any unexpected delete in a caller’s plan blocks the change: add a moved block or rethink
the refactor. Never run apply on a caller without the user’s confirmation.
Output
**Module:** modules/s3-bucket v2.3.0 -> v3.0.0 (major: lifecycle_rules type changed)
**Interface:** +lifecycle_rules map(object), -expiration_days (replaced), outputs unchanged
**Refactor:** 2 moved blocks (count -> for_each); caller plans show 0 destroy, 2 moved
**Checks:** fmt, validate, tflint clean; 4 terraform test runs pass with mock provider
**Callers to update:** envs/prod, envs/staging (PR drafted, not applied) What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Terraform Modules"
short_description: "Small typed interfaces, refactors with zero destroys"
default_prompt: "$terraform-modules Turn these copy-pasted S3 bucket resources into a reusable Terraform module" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.