6 min read
Terraform Module Structure
A module's inputs and outputs are its real interface, remote state with locking isn't optional, and pinned versions keep `init` reproducible.
Principles
A module's inputs and outputs are its actual interface
Everything else inside is an implementation detail the caller shouldn't need to know about. A module that forces callers to reach into its internals (via `terraform state` or hardcoded resource addresses) has leaked its interface.
State is shared, mutable, and remote for a reason
Local state files are a single point of failure and a merge conflict waiting to happen the moment two people run `apply`. Remote state with locking (S3 + DynamoDB, or Terraform Cloud) is not optional past a single contributor.
Workspaces or directories separate environments — pick one, don't mix
The same `.tf` files can serve dev/staging/prod via `terraform workspace`, or via separate directories with separate state. Sharing the same state file across environments makes a `terraform destroy` in dev capable of destroying prod.
A module version pin is a promise about behaviour, not just syntax
An unpinned module source — a git ref without a tag, or a registry source without a version constraint — means `terraform init` can silently pull in breaking changes between one teammate's run and another's.
`plan` output is the actual code review
A `.tf` diff tells you what changed in the file. The `plan` output tells you what changes in reality, including second-order effects (a security group rename that forces-replaces every instance using it) the file diff doesn't show.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Module leaking its internals
Avoid
# Caller has to know the exact resource address inside the module
# to do anything beyond what the module explicitly exposed.
resource "aws_iam_role_policy_attachment" "extra" {
role = module.app_service.iam_role.name # reaching into internals
policy_arn = aws_iam_policy.extra.arn
}The caller depends on the module's internal resource structure, which the module's own author is now afraid to refactor.
Prefer
# module "app_service" exposes exactly what callers need:
# output "iam_role_name" { value = aws_iam_role.this.name }
resource "aws_iam_role_policy_attachment" "extra" {
role = module.app_service.iam_role_name # via the declared interface
policy_arn = aws_iam_policy.extra.arn
}The module's internals can be refactored freely as long as the declared output keeps meaning the same thing.
Shared state across environments
Avoid
# Same backend config, same state file, used for both dev and prod
# by switching which .tfvars is passed at apply time.
terraform {
backend "s3" {
bucket = "tf-state"
key = "app/terraform.tfstate"
}
}
# terraform destroy -var-file=dev.tfvars <- can destroy PROD's stateThe state file, not the tfvars, is what Terraform actually diffs against — sharing it means a dev-intended destroy can take out prod resources.
Prefer
terraform {
backend "s3" {
bucket = "tf-state"
key = "app/${terraform.workspace}/terraform.tfstate"
}
}
# terraform workspace select prod
# terraform apply -var-file=prod.tfvarsEach environment's state lives at its own key — a mistaken command in dev has no path to touching prod's state at all.
Unpinned module source
Avoid
module "vpc" {
source = "git::https://github.com/org/tf-modules.git//vpc" # no ref
}
# terraform init today pulls a different commit than
# terraform init six months ago.The module's actual behaviour can change between two `init` runs with zero changes to this file — reproducibility is gone.
Prefer
module "vpc" {
source = "git::https://github.com/org/tf-modules.git//vpc?ref=v2.3.0"
}The module version is pinned explicitly — the same `.tf` file always resolves to the same module behaviour.
Review checklist
What I look for when reviewing a pull request that touches this area.
- Every module exposes a deliberate set of `output`s; callers never reach into a module's internal resource addresses.
- Remote state with locking is configured before more than one person runs `apply` against it.
- Environments are separated by workspace or directory (pick one strategy), never sharing a single state file.
- Every module source is pinned to a specific version or ref, not tracking a branch's HEAD.
- `terraform plan` output is reviewed for second-order effects (forced replacements, unexpected deletions), not just the `.tf` diff.
- `terraform fmt` and `terraform validate` run in CI before any `plan`/`apply`.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/terraform-module-structure/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: terraform-module-structure
description: >-
Terraform module structure: deliberate input/output interfaces, remote
state with locking, environment separation via workspace or directory,
pinned module versions, and reviewing plan output for second-order
effects. Use when writing or reviewing Terraform modules.
argument-hint: "[module or environment]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Terraform Module Structure
Apply when writing a Terraform module or reviewing a plan. Before adding an output, ask whether a caller genuinely needs it — the output list is the module's real interface, and every entry is a promise to keep it working.
## Rules
1. **A module's inputs and outputs are its actual interface** — Everything else inside is an implementation detail the caller shouldn't need to know about. A module that forces callers to reach into its internals (via `terraform state` or hardcoded resource addresses) has leaked its interface.
2. **State is shared, mutable, and remote for a reason** — Local state files are a single point of failure and a merge conflict waiting to happen the moment two people run `apply`. Remote state with locking (S3 + DynamoDB, or Terraform Cloud) is not optional past a single contributor.
3. **Workspaces or directories separate environments — pick one, don't mix** — The same `.tf` files can serve dev/staging/prod via `terraform workspace`, or via separate directories with separate state. Sharing the same state file across environments makes a `terraform destroy` in dev capable of destroying prod.
4. **A module version pin is a promise about behaviour, not just syntax** — An unpinned module source — a git ref without a tag, or a registry source without a version constraint — means `terraform init` can silently pull in breaking changes between one teammate's run and another's.
5. **`plan` output is the actual code review** — A `.tf` diff tells you what changed in the file. The `plan` output tells you what changes in reality, including second-order effects (a security group rename that forces-replaces every instance using it) the file diff doesn't show.
## Patterns
### Module leaking its internals
**Avoid** — The caller depends on the module's internal resource structure, which the module's own author is now afraid to refactor.
```hcl
# Caller has to know the exact resource address inside the module
# to do anything beyond what the module explicitly exposed.
resource "aws_iam_role_policy_attachment" "extra" {
role = module.app_service.iam_role.name # reaching into internals
policy_arn = aws_iam_policy.extra.arn
}
```
**Prefer** — The module's internals can be refactored freely as long as the declared output keeps meaning the same thing.
```hcl
# module "app_service" exposes exactly what callers need:
# output "iam_role_name" { value = aws_iam_role.this.name }
resource "aws_iam_role_policy_attachment" "extra" {
role = module.app_service.iam_role_name # via the declared interface
policy_arn = aws_iam_policy.extra.arn
}
```
### Shared state across environments
**Avoid** — The state file, not the tfvars, is what Terraform actually diffs against — sharing it means a dev-intended destroy can take out prod resources.
```hcl
# Same backend config, same state file, used for both dev and prod
# by switching which .tfvars is passed at apply time.
terraform {
backend "s3" {
bucket = "tf-state"
key = "app/terraform.tfstate"
}
}
# terraform destroy -var-file=dev.tfvars <- can destroy PROD's state
```
**Prefer** — Each environment's state lives at its own key — a mistaken command in dev has no path to touching prod's state at all.
```hcl
terraform {
backend "s3" {
bucket = "tf-state"
key = "app/${terraform.workspace}/terraform.tfstate"
}
}
# terraform workspace select prod
# terraform apply -var-file=prod.tfvars
```
### Unpinned module source
**Avoid** — The module's actual behaviour can change between two `init` runs with zero changes to this file — reproducibility is gone.
```hcl
module "vpc" {
source = "git::https://github.com/org/tf-modules.git//vpc" # no ref
}
# terraform init today pulls a different commit than
# terraform init six months ago.
```
**Prefer** — The module version is pinned explicitly — the same `.tf` file always resolves to the same module behaviour.
```hcl
module "vpc" {
source = "git::https://github.com/org/tf-modules.git//vpc?ref=v2.3.0"
}
```
## Review checklist
- [ ] Every module exposes a deliberate set of `output`s; callers never reach into a module's internal resource addresses.
- [ ] Remote state with locking is configured before more than one person runs `apply` against it.
- [ ] Environments are separated by workspace or directory (pick one strategy), never sharing a single state file.
- [ ] Every module source is pinned to a specific version or ref, not tracking a branch's HEAD.
- [ ] `terraform plan` output is reviewed for second-order effects (forced replacements, unexpected deletions), not just the `.tf` diff.
- [ ] `terraform fmt` and `terraform validate` run in CI before any `plan`/`apply`.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim