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.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- terraform-modules

Installs to ~/.agents/skills/terraform-modules/ for every project on this machine.

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.

~/.agents/skills/terraform-modules/SKILL.md

--- name: terraform-modules description: Designs, refactors and publishes Terraform modules - file layout, typed variables with optional attributes and validation, minimal outputs, provider… license: MIT ---

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

  1. Read AGENTS.md, then versions.tf/terraform blocks for the Terraform and provider versions in use (terraform version). Features below note their minimum version.
  2. Find every caller of the module (rg -n 'source\s*=.*modules/<name>') and the state they manage. An interface change affects all of them.
  3. Check for existing tooling: .tflint.hcl, tests/, pre-commit hooks, terraform-docs config.
  4. Run terraform init -backend=false && terraform validate in 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

SituationDecision
The same 3+ resources are repeated with small differencesModule
A single resource with a few argumentsNo 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 placesSeparate 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)), never any for structured input. optional(type, default) needs Terraform 1.3+.
  • nullable = false lets callers pass null and get the default.
  • Validation blocks referencing other variables need 1.9+; on older versions use a precondition in a lifecycle block 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_each over a map or set of strings for collections. count reorders addresses when an item is removed from the middle, which destroys and recreates everything after it.
  • count = condition ? 1 : 0 is fine for one optional resource.
  • Name the primary resource this (or a meaningful noun when there are several).
  • for_each keys 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 without moved blocks.
  • Callers pin: registry version = "~> 2.3"; Git source = "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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem