Use when designing Terraform modules for reuse, structuring root vs child modules, choosing variable shapes (object vs flat list), wiring versioning + module registries, managing remote state, detecting and remediating drift, or refactoring monolithic configs. Triggers: module composition, var validation blocks, output contracts, count vs for_each, lifecycle ignore_changes, terraform import, state mv between resources, workspaces vs separate state files, OIDC to provider, drift detection runs. NOT for Pulumi/CDK (different paradigms), CloudFormation, ad-hoc one-off deployments, or Terraform Cloud-specific UX.
Instrucciones de origen · Vista previa de solo lectura
license
Apache-2.0
allowed-tools
Read,Write,Edit,Bash,Glob,Grep,WebSearch,WebFetch
name
terraform-module-design
description
Use when designing Terraform modules for reuse, structuring root vs child modules, choosing variable shapes (object vs flat list), wiring versioning + module registries, managing remote state, detecting and remediating drift, or refactoring monolithic configs. Triggers: module composition, var validation blocks, output contracts, count vs for_each, lifecycle ignore_changes, terraform import, state mv between resources, workspaces vs separate state files, OIDC to provider, drift detection runs. NOT for Pulumi/CDK (different paradigms), CloudFormation, ad-hoc one-off deployments, or Terraform Cloud-specific UX.
metadata
{"category":"DevOps & Infrastructure","tags":["terraform","iac","modules","state","hashicorp","cloud"],"provenance":{"kind":"first-party","owners":["port-daddy"]},"pairs-with":[{"skill":"monitoring-stack-deployer","reason":"Once modules provision the infrastructure, that skill wires the observability stack watching it -- often as a caller of the modules designed here."},{"skill":"agentic-infrastructure-2026","reason":"The broader infra-architecture context (what to provision and why) that this skill's module boundaries and state-splitting decisions serve."}],"io-contract":{"kind":"deliverable","consumes":["[Truncated]","[Truncated]"],"produces":["[Truncated]","[Truncated]"]}}
Terraform Module Design
A good Terraform module is a black box: clear inputs, stable outputs, locked-down dependencies. The shift from "configuration files" to "module library" is what separates a working repo from a maintainable one.
# modules/service/variables.tf
variable "name" {
type = string
description = "Service name. Used in resource names; lowercase + dashes."
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,30}$", var.name))
error_message = "name must be 3-31 chars, start with a letter, lowercase + dashes."
}
}
variable "scaling" {
type = object({
min = number
max = number
cpu = number
})
default = { min = 1, max = 10, cpu = 50 }
}
variable "tags" {
type = map(string)
default = {}
}
Validation blocks turn unclear runtime errors into clear plan-time errors. Use them on every variable that has constraints.
Outputs as the contract
# modules/service/outputs.tf
output "service_url" {
value = aws_ecs_service.this.url
description = "HTTPS URL of the deployed service."
}
output "log_group" {
value = aws_cloudwatch_log_group.this.name
description = "Log group name for downstream alerts."
}
output "_resources" {
value = { service_arn = aws_ecs_service.this.arn, role_arn = aws_iam_role.this.arn }
description = "Internal resource references for sibling modules."
sensitive = false
}
Outputs are the public API. Once a module is used by multiple callers, removing or renaming an output is a breaking change.
Versioning + registry
Tag releases:
git tag -a service/v1.4.0 -m "service module v1.4.0"
git push origin service/v1.4.0
Caller pins:
module "orders_api" {
source = "git::https://github.com/myorg/tf-modules.git//service?ref=service/v1.4.0"
name = "orders-api"
scaling = { min = 2, max = 20, cpu = 60 }
}
For a private registry, the source becomes myorg.com/service/aws with version constraints (version = "~> 1.4"). Never source from main — invisible upstream changes break everything downstream.
Always prefer for_each over count for dynamic resources. count is fine for "create N identical things"; for_each is for any case where the items have identity.
State separation: one state file per (environment × stack). Don't put dev + prod in the same state. Workspaces are a third option but generally discouraged for prod isolation.
Refactoring without recreate
# moved.tf — keep alongside the refactor.
moved {
from = aws_lb.app
to = module.network.aws_lb.app
}
moved blocks (1.1+) tell Terraform "this resource moved within state." Without them, refactor → destroy + create → outage.
For cross-state moves: terraform state mv (manual) or import blocks (1.5+):
import {
to = aws_s3_bucket.this
id = "existing-bucket-name"
}
Drift detection
# Plan against current cloud state. Non-zero exit if drift.
terraform plan -detailed-exitcode -lock=false# Production drift detection in CI (read-only).
terraform plan -detailed-exitcode -lock=false || echo"drift detected"
Schedule a daily drift check. Non-zero exit codes mean a human edited something out-of-band — investigate.
Pin major + minor. Patch range is fine. >= 1.7 keeps you within tested territory.
Anti-patterns
Module sourced from main
Symptom: Plan output changes mysteriously between runs.
Diagnosis: Floating reference; upstream changed.
Fix: Pin to a tag or SHA. Update intentionally.
One state file for everything
Symptom: A small change requires apply on the entire infrastructure.
Diagnosis: Monolithic state.
Fix: Split by blast radius: networking, data, services. Cross-reference via terraform_remote_state or outputs.
count for resources with identity
Symptom: Removing a user from the middle of the list recreates everyone after.
Diagnosis:count.index reorders when the list does.
Fix:for_each = toset(...) — keys are stable.
ignore_changes blanket-applied
Symptom: Drift goes undetected; security misconfig persists.
Diagnosis:ignore_changes = all on a resource that should be authoritative.
Fix: Specific paths only: ignore_changes = [task_definition]. Never all.
Plan-apply-pray
Symptom: Apply mid-plan caused incident.
Diagnosis: Apply ran on stale plan; another engineer changed state.
Fix:terraform plan -out=plan.tfplan && terraform apply plan.tfplan. The plan file binds the apply to a specific state version.
Secrets in tfvars
Symptom: Database password committed to repo.
Diagnosis:terraform.tfvars checked in.
Fix: Sensitive variables from environment (TF_VAR_db_password), Vault, or AWS Secrets Manager. Mark with sensitive = true in declaration.
Quality gates
Every module pins source to a tag or SHA, never main.
Every variable has a description and validation (where applicable).
Outputs documented; deprecation handled with moved blocks.
State backend is remote, encrypted, and locked.
Drift check runs daily; non-zero exits are investigated.
No secrets in tfvars or state-readable plain text.
for_each used over count for resources with identity.
ignore_changes is targeted, never all.
terraform plan -out then apply plan.tfplan in CI.
Provider + Terraform versions pinned.
Deterministic Audit
Before committing to a module design (or reviewing another agent's), write it as a JSON
plan matching schemas/terraform-module-plan.schema.json and run the deterministic
auditor:
auditTerraformModule(plan) (in scripts/terraform_module_audit.mjs) turns this
skill's anti-patterns and Quality Gates into machine-checkable rules over structured
fields — no keyword matching: a module sourced from a floating branch, count on
resources with identity, blanket ignore_changes = all, a local or unlocked state
backend, secrets in tfvars, plan-apply-pray (apply not bound to a saved plan file),
missing drift checks, unpinned providers, and long-lived CI credentials instead of
OIDC. It returns { pass, score, findings, recommendations }.
examples/sample-input.json is a tag-pinned, remote-locked-state plan (pass: true).
Version history lives in CHANGELOG.md.
NOT for
Pulumi / AWS CDK — different paradigms. No dedicated skill.
CloudFormation / ARM / Bicep — provider-native, different idioms. No dedicated skill.
One-off deployments — Terraform's overhead doesn't pay off; just script it. No dedicated skill.
Terraform Cloud / HCP-specific UX — overlaps but has product-specific features. No dedicated skill.
Kubernetes manifest management — IaC neighbor, but Helm/Kustomize is its own domain. → kubernetes-debugging-runbook for runtime debugging only.
Observability of the cloud you provisioned — once it's up, → grafana-dashboard-builder and opentelemetry-instrumentation.