Terraform
Overview
Opinionated conventions for safe, minimal, well-typed Terraform modules. Configuration is data; expose the smallest knob surface that supports real use cases. Bad input should fail at parse, not at plan a day later in CI.
RELATED SKILLS: General test discipline (TDD, black-box testing, naming) lives in tests and superpowers:test-driven-development. This skill only adds Terraform-specific test notes.
When to use
- Writing, modifying or reviewing any
.tf / .hcl or .tftest.hcl file
- Designing a module's input or output surface
Do
- Format, lint, validate:
terraform fmt -recursive, tflint in the module directory, then terraform validate. All three must pass before ship. Ignore failures in .terraform/modules/ (downloaded deps).
- Pin everything.
required_version and every entry in required_providers. Commit .terraform.lock.hcl.
- Specific types on every variable.
object({...}), list(string), map(...). Never any on a complex input.
optional(T, default) for non-required object fields.
- Validate inputs aggressively.
validation blocks with a concrete condition and a clear error_message.
- Reject invalid combinations at the variable level. If
enable_x = true requires x_arn, enforce it with a validation.
- Hardcode in
main.tf over exposing a variable. If you can't name a consumer that would set it differently, don't add the knob.
for_each over count. Map keys survive reorders; count indexes don't.
moved {} blocks for renames or refactors. Never destroy and recreate just to rename.
removed {} blocks when dropping a resource you want to keep. With lifecycle { destroy = false } to only remove from state.
import {} blocks to adopt existing infra. Reviewable in a PR, visible in plan. Imperative terraform import is neither.
sensitive = true on variables and outputs holding secrets, tokens, ARNs, or PII.
- One module, one purpose. Outputs expose only what consumers need.
data sources for account ID, region, partition. Never hardcode 123456789012 or us-east-1.
- Default tags on the provider (e.g. AWS
default_tags) or a single tags module. Don't sprinkle tags = merge(...) across every resource.
- Concise tests that guard invariants.
terraform test is verbose; use it only for what would silently break consumers (required outputs, validation rejection, key resource attributes).
- Comments explain why. A workaround, an upstream bug, a non-obvious ordering. Don't narrate what the HCL already says.
- Early returns via
try() / coalesce() beat deeply nested ternaries.
Don't
- Don't expose a knob you can't safely combine. If
var.a = true requires var.b = false, that's a footgun, not a feature - constrain it or remove it.
- Don't
depends_on to paper over a missing reference. Reference an attribute of B from A instead.
- Don't reach for
null_resource / local-exec when a provider resource exists.
- Don't manually edit state. Use
moved, removed, or an import {} block. terraform state rm and terraform import (the CLI) are last resorts, not workflows.
- Don't use
count = var.enabled ? 1 : 0 when for_each over a (possibly empty) set reads more clearly and survives future changes.
- Don't test trivial wiring or upstream behaviour. Test the contract your module promises.
Rationalizations: when adding a variable
Common excuses for exposing a knob you shouldn't. If you catch yourself reaching for one, hardcode the literal in main.tf instead.
| Excuse |
Reality |
| "Flexibility for future deployments" |
Future deployments rarely materialize. Add the variable when a real consumer needs it; the change is small. |
| "Different deployments might answer differently" |
Name the deployment. If you can't, it's one answer. |
| "It's just one more knob" |
One more knob is one more invalid combination and one more thing to validate. |
| "Upstream exposes it, so we should too" |
Upstream serves many consumers; this module may serve one operating model. Match yours, not theirs. |
Quick reference
| Need |
Pattern |
| Format |
terraform fmt -recursive |
| Lint (current module) |
tflint (don't --recursive into deps) |
| Validate |
terraform validate |
| Run tests |
terraform test |
| Rename a resource |
moved { from = ... to = ... } |
| Adopt existing infra |
import { to = ... id = "..." } |
| Drop without destroy |
removed { from = ... lifecycle { destroy = false } } |
| Optional object field |
optional(string, null) |
| Reject bad input |
validation { condition = ... error_message = ... } |
| Iterate a map |
for k, v in var.things : k => ... |
| Sensitive output |
output "x" { value = ...; sensitive = true } |
Validation pattern
variable "cluster_name" {
type = string
description = "EKS cluster name. <= 100 chars, DNS-1123 compatible."
validation {
condition = length(var.cluster_name) <= 100 && can(regex("^[a-z0-9-]+$", var.cluster_name))
error_message = "cluster_name must be <= 100 lowercase alphanumeric or hyphen characters."
}
}
variable "logging" {
type = object({
enabled = bool
bucket_arn = optional(string)
retention_days = optional(number, 30)
})
validation {
condition = !var.logging.enabled || var.logging.bucket_arn != null
error_message = "logging.bucket_arn is required when logging.enabled = true."
}
}
The second block is the key pattern: cross-field constraints belong at the variable, not in plan-time errors.
Source: FollowTheProcess/dotfiles — distributed by TomeVault.
1---2name: terraform-243description: Use when writing, modifying, reviewing, or reading Terraform (HCL) code.4---56# Terraform78## Overview910Opinionated conventions for safe, minimal, well-typed Terraform modules. Configuration is data; expose the smallest knob surface that supports real use cases. Bad input should fail at parse, not at plan a day later in CI.1112**RELATED SKILLS:** General test discipline (TDD, black-box testing, naming) lives in `tests` and `superpowers:test-driven-development`. This skill only adds Terraform-specific test notes.1314## When to use1516- Writing, modifying or reviewing any `.tf` / `.hcl` or `.tftest.hcl` file17- Designing a module's input or output surface1819## Do2021- **Format, lint, validate:** `terraform fmt -recursive`, `tflint` in the module directory, then `terraform validate`. All three must pass before ship. Ignore failures in `.terraform/modules/` (downloaded deps).22- **Pin everything.** `required_version` and every entry in `required_providers`. Commit `.terraform.lock.hcl`.23- **Specific types on every variable.** `object({...})`, `list(string)`, `map(...)`. Never `any` on a complex input.24- **`optional(T, default)`** for non-required object fields.25- **Validate inputs aggressively.** `validation` blocks with a concrete `condition` and a clear `error_message`.26- **Reject invalid combinations at the variable level.** If `enable_x = true` requires `x_arn`, enforce it with a `validation`.27- **Hardcode in `main.tf` over exposing a variable.** If you can't name a consumer that would set it differently, don't add the knob.28- **`for_each` over `count`.** Map keys survive reorders; `count` indexes don't.29- **`moved {}` blocks for renames or refactors.** Never destroy and recreate just to rename.30- **`removed {}` blocks when dropping a resource you want to keep.** With `lifecycle { destroy = false }` to only remove from state.31- **`import {}` blocks to adopt existing infra.** Reviewable in a PR, visible in `plan`. Imperative `terraform import` is neither.32- **`sensitive = true`** on variables and outputs holding secrets, tokens, ARNs, or PII.33- **One module, one purpose.** Outputs expose only what consumers need.34- **`data` sources for account ID, region, partition.** Never hardcode `123456789012` or `us-east-1`.35- **Default tags on the provider** (e.g. AWS `default_tags`) or a single tags module. Don't sprinkle `tags = merge(...)` across every resource.36- **Concise tests that guard invariants.** `terraform test` is verbose; use it only for what would silently break consumers (required outputs, validation rejection, key resource attributes).37- **Comments explain *why*.** A workaround, an upstream bug, a non-obvious ordering. Don't narrate what the HCL already says.38- **Early returns via `try()` / `coalesce()`** beat deeply nested ternaries.3940## Don't4142- **Don't expose a knob you can't safely combine.** If `var.a = true` requires `var.b = false`, that's a footgun, not a feature - constrain it or remove it.43- **Don't `depends_on` to paper over a missing reference.** Reference an attribute of B from A instead.44- **Don't reach for `null_resource` / `local-exec`** when a provider resource exists.45- **Don't manually edit state.** Use `moved`, `removed`, or an `import {}` block. `terraform state rm` and `terraform import` (the CLI) are last resorts, not workflows.46- **Don't use `count = var.enabled ? 1 : 0`** when `for_each` over a (possibly empty) set reads more clearly and survives future changes.47- **Don't test trivial wiring or upstream behaviour.** Test the contract *your* module promises.4849## Rationalizations: when adding a variable5051Common excuses for exposing a knob you shouldn't. If you catch yourself reaching for one, hardcode the literal in `main.tf` instead.5253| Excuse | Reality |54|-----------------------------------------------------|-------------------------------------------------------------------------|55| "Flexibility for future deployments" | Future deployments rarely materialize. Add the variable when a real consumer needs it; the change is small. |56| "Different deployments might answer differently" | Name the deployment. If you can't, it's one answer. |57| "It's just one more knob" | One more knob is one more invalid combination and one more thing to validate. |58| "Upstream exposes it, so we should too" | Upstream serves many consumers; this module may serve one operating model. Match yours, not theirs. |5960## Quick reference6162| Need | Pattern |63|-------------------------|----------------------------------------------------------|64| Format | `terraform fmt -recursive` |65| Lint (current module) | `tflint` (don't `--recursive` into deps) |66| Validate | `terraform validate` |67| Run tests | `terraform test` |68| Rename a resource | `moved { from = ... to = ... }` |69| Adopt existing infra | `import { to = ... id = "..." }` |70| Drop without destroy | `removed { from = ... lifecycle { destroy = false } }` |71| Optional object field | `optional(string, null)` |72| Reject bad input | `validation { condition = ... error_message = ... }` |73| Iterate a map | `for k, v in var.things : k => ...` |74| Sensitive output | `output "x" { value = ...; sensitive = true }` |7576## Validation pattern7778```hcl79variable "cluster_name" {80 type = string81 description = "EKS cluster name. <= 100 chars, DNS-1123 compatible."82 validation {83 condition = length(var.cluster_name) <= 100 && can(regex("^[a-z0-9-]+$", var.cluster_name))84 error_message = "cluster_name must be <= 100 lowercase alphanumeric or hyphen characters."85 }86}8788variable "logging" {89 type = object({90 enabled = bool91 bucket_arn = optional(string)92 retention_days = optional(number, 30)93 })94 validation {95 condition = !var.logging.enabled || var.logging.bucket_arn != null96 error_message = "logging.bucket_arn is required when logging.enabled = true."97 }98}99```100101The second block is the key pattern: cross-field constraints belong at the variable, not in plan-time errors.102103---104> Source: [FollowTheProcess/dotfiles](https://github.com/FollowTheProcess/dotfiles) — distributed by [TomeVault](https://tomevault.io).105<!-- tomevault:4.0:skill_md:2026-06-15 -->