All topics
AWS + Terraform

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

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
}

The caller depends on the module's internal resource structure, which the module's own author is now afraid to refactor.

Prefer

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
}

The module's internals can be refactored freely as long as the declared output keeps meaning the same thing.

Shared state across environments

Avoid

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

The state file, not the tfvars, is what Terraform actually diffs against — sharing it means a dev-intended destroy can take out prod resources.

Prefer

hcl
terraform {
  backend "s3" {
    bucket = "tf-state"
    key    = "app/${terraform.workspace}/terraform.tfstate"
  }
}
# terraform workspace select prod
# terraform apply -var-file=prod.tfvars

Each 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

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.

The module's actual behaviour can change between two `init` runs with zero changes to this file — reproducibility is gone.

Prefer

hcl
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.

Download
.claude/skills/terraform-module-structure/SKILL.md

Drop 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