Terraform Modules from First Principles: Deterministic, Declarative, Idempotent
Terraform modules are often treated like “just a folder of .tf files”. But when a module gets reused across projects and environments, it behaves more like a function:
- Inputs: variables
- Outputs: values you return
- Side effects: creating/changing real infrastructure
From first principles, a “good” module is one you can reason about. That means it should behave predictably when you run it today, tomorrow, or from CI.
Three principles help you get there:
- Determinism: same inputs → same plan → same outcome
- Declarative design: describe what you want, not how to do it
- Idempotency: repeated applies converge to no-op
They’re related, but not identical. Terraform nudges you toward all three—but module design decides whether you actually get them.
A quick map of the three
- Declarative is about interface and intent.
- Deterministic is about predictability and reproducibility.
- Idempotent is about safe repetition and convergence.
You can violate one while keeping the others. For example:
- A module can be declarative but not deterministic (e.g., “latest AMI”).
- A module can be deterministic but not idempotent (e.g., deterministic timestamps that force drift).
The goal isn’t “purity”. The goal is: default to these properties so that breaking them is a conscious choice.
1) Determinism (reproducibility)
What it means
A module is deterministic when:
- With the same inputs
- And the same assumptions about external state
terraform planproduces the same diff- And
terraform applyproduces the same infrastructure
If you’ve ever asked “why did this change?” without changing code, you’ve felt what non-determinism looks like.
Why it matters for modules
Modules exist to be reused. Reuse implies:
- multiple environments (dev/stage/prod)
- multiple callers (teams)
- multiple execution contexts (laptop vs CI)
Non-determinism creates:
- Perpetual diffs that hide real changes
- Flaky pipelines where approvals stop meaning anything
- Unreproducible incidents (“worked yesterday with the same config”)
Practical module patterns for determinism
- Pin provider and module versions.
- Use stable identities for collections (
for_eachkeys). - Avoid “moving targets” unless explicitly requested.
- Make naming either explicit (
nameinput) or derived from stable inputs.
Example: stable for_each keys
If your identity is “the thing itself”, the key should be the thing itself.
variable "subnet_ids" {
type = list(string)
}
# Good: stable keys based on subnet IDs
resource "aws_route_table_association" "this" {
for_each = toset(var.subnet_ids)
subnet_id = each.value
route_table_id = aws_route_table.this.id
}
Common ways modules accidentally become non-deterministic
-
“Latest” lookups in data sources
Example: choosing an AMI by “most recent”. That can change tomorrow, even if inputs didn’t.
-
Unstable identity (list index as identity)
Reordering a list shouldn’t replace resources, but if you key by index it will.
-
Time-based values in arguments
timestamp()in a tag or name guarantees drift. -
Environment-dependent behavior
Reading
TF_VAR_*,AWS_PROFILE, current region, or local filesystem in ways that aren’t explicitly modeled as inputs.
Good reasons to break determinism
Sometimes “reproducible” is not the top priority.
- Security patching: track a patched “latest” image
- Managed platform defaults: accept the provider’s “latest supported” version for an addon
- Ephemeral preview stacks: you want fresh and disposable
If you do this, make it explicit:
- add a clear input like
version_strategy = "pinned" | "latest"(default: pinned) - output the chosen version/AMI so it’s visible in plan/state
- document the tradeoff
2) Declarative module design (intent over steps)
What it means
Terraform is declarative: you describe desired state; Terraform computes the dependency graph.
A module is declarative when its interface is “describe what you want”, not “tell me what steps to run”.
Why it matters for modules
Declarative modules are:
- Composable: they fit into a bigger graph
- Reviewable: diffs show desired state, not procedure
- Portable: fewer assumptions about where/how they’re applied
A useful test
If someone uses your module, do they need a runbook like:
- apply module A
- wait 10 minutes
- run a script
- apply module B
If yes, the module is probably carrying hidden procedural requirements.
Example: intent-driven inputs
Prefer modeling desired outcomes:
variable "logging" {
type = object({
enabled = bool
retention_days = number
})
}
Your module can then decide which resources to create and how to wire them, based on the intent.
Common ways modules become non-declarative
-
null_resource+local-exec/remote-execas the primary mechanismThis turns Terraform into a step runner, and Terraform can’t properly track the side effects.
-
Manual orchestration requirements
“Apply this first, then that” often means the module is leaking internal sequencing.
-
Hidden dependencies via naming conventions
Example: “this module expects a bucket named
my-company-logsto exist”.
Good reasons to be less declarative
There are legitimate “bootstrap” style operations:
- initializing a system (first admin user)
- seeding one-time configuration
- provider gaps where a feature isn’t modeled
Mitigations:
- keep bootstrap logic in a separate module (
*-bootstrap) - make it opt-in (
enable_bootstrap = falseby default) - prefer dedicated providers over shelling out
3) Idempotency (safe repetition)
What it means
A module is idempotent when:
- first apply makes changes
- second apply (no config changes) results in no changes
- repeated runs converge
This is what makes “apply from CI” safe.
Why it matters for modules
Non-idempotent modules create:
- constant churn
- risky redeploys
- alert fatigue and distrust in plans
Example: making “force redeploy” explicit
If you want an escape hatch to force a change, model it as an explicit input.
variable "deployment_id" {
type = string
default = ""
description = "Change this to force a redeploy. Keep constant for idempotent applies."
}
The important design idea: idempotency should be the default, and non-idempotent behavior should be a deliberate toggle.
Common designs that break idempotency
-
Embedding build IDs or timestamps into steady-state arguments
If
user_data, tags, names, or annotations change on every pipeline run, Terraform will churn. -
Provider normalization diffs
Some APIs return values in a different format than you send (ordering, casing). Modules that don’t anticipate this can produce perpetual diffs.
-
Imperative scripts with side effects that Terraform can’t observe
A
local-execthat creates something in an external system will often run again because Terraform can’t “see” it.
Good reasons to sacrifice idempotency
- preview environments designed to be replaced frequently
- intentional rotation of credentials
Mitigations:
- gate behavior behind explicit flags
- clearly name the flag (
force_recreate,rotate_now) - default it to
false
A small “anti-pattern catalog” (what to watch for)
- Identity by list index: resource replacement on reorder
- Implicit moving targets: “latest” without an explicit strategy
- Time/random in arguments: drift by design
- Procedural execs:
null_resourcedriving the real work - Hidden conventions: dependencies that aren’t expressed as inputs/outputs
If you spot these in a module, it doesn’t mean it’s “bad”—it means the module is paying interest in operational complexity.
Closing: design for reasoning
If I had to compress the whole post into one sentence:
A Terraform module should behave like a predictable function; if it can’t, make the unpredictability explicit.
Default to determinism, declarative intent, and idempotent convergence.
Then, if you need to break the rules, do it for a reason—and encode that reason into the module interface so your future self doesn’t have to rediscover it.