AWS Jul 6, 2026 · 8 min read

OpenTofu modules you won't hate six months from now

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

An infrastructure module is one of those things you write once and suffer many times. You start with a clean module to stand up a service on ECS Fargate, and six months later it's a monster with forty variables, three booleans that toggle incompatible behaviors, and a nested count nobody understands. I migrated several projects from Terraform to OpenTofu and along the way rewrote almost all my modules, and what I learned isn't about syntax: it's about resisting the temptation to make one module do everything. This is the set of rules I follow today so infrastructure as code stays an asset and not a millstone.

A module models a concept, not a list of resources

The mistake I made most often was building modules around "things I usually deploy together" instead of around a concept with a clear boundary. A "web-service" module that creates the ECS service, its load balancer, its DNS, its database and its alarms feels convenient, but it couples decisions that change at different rates. The day you want a service without a database, or one sharing a database with another, the module fights you.

The rule that works for me is that a module should model a concept worth naming on its own. "A service on Fargate" is a concept. "An SQS queue with its DLQ" is a concept. "Everything project X needs" is not: it's a composition, and compositions belong in the root, not inside a module. When each module has a clean conceptual boundary, composing them is easy and swapping one for another doesn't break the rest.

Configuration booleans are debt in disguise

Every time you add an enable_something variable to a module, you're introducing a fork in its behavior. With two or three flags it's still understandable. With eight, your module has hundreds of possible combinations and you've only tested the four you use. The rest are minefields waiting for someone.

# The module that ages badly: configured with flags
# that toggle incompatible branches of behavior.
module "service" {
  source          = "./modules/service"
  enable_https    = true
  enable_autoscaling = true
  enable_spot     = false
  enable_efs      = true
  # ...and on until nobody knows which combinations work
}

I prefer smaller modules with fewer options, and I resolve variation by composing instead of configuring. If one service needs persistent storage and another doesn't, I don't add an enable_efs flag: I make the volume a separate module I wire in when needed. The service module knows nothing about EFS and has no untested branch. The composition shows up in the root, where it should, not hidden behind a boolean.

Outputs are the contract; treat them like an API

What a module exposes in its outputs is its public interface, and changing it breaks consumers just as you'd break the clients of an API. Early on I exposed everything "just in case", and ended up with modules whose consumers depended on internal details I wanted to change. Now I treat outputs with the same discipline as a contract: I expose the minimum a consumer needs to wire this module to another —an ARN, an id, an endpoint— and none of the internal plumbing.

# Expose identifiers to compose with, not whole resources.
output "service_arn" {
  value = aws_ecs_service.this.id
}

output "task_role_arn" {
  # Other modules attach policies to this role;
  # that's the extension point I want to offer.
  value = aws_iam_role.task.arn
}

Exposing a role's ARN instead of the whole role is a small but important example: it gives the consumer the exact point to extend (attach a policy) without opening the full resource for them to poke at things that would break my module.

💡 Before adding a variable to a module, ask whether the variation belongs inside the module or in whatever composes it. Most of the time it belongs outside. A module with few inputs and clear outputs gets reused; one with twenty flags gets copy-pasted.

State is where design mistakes get paid

In IaC the design isn't truly tested until you apply changes over existing infrastructure, and that's where state sends you the bill. A badly bounded module shows itself when a plan wants to destroy and recreate something you only meant to nudge, because you moved a resource around or swapped a count for a for_each. Migrating to OpenTofu taught me to treat state structure as part of the design, not a detail: moved blocks exist precisely to refactor without destroying, and using them carefully is what lets you reorganize modules without a scare in production.

I chose small, clearly bounded modules knowing the price: there are more pieces to compose in the root and composition is more verbose than one mega-module with flags. In exchange, each piece is understandable, testable and replaceable on its own, and a change in one doesn't threaten to recreate half the infrastructure. For code that will live for years and that I apply changes to every week, that predictability is worth far more than the convenience of writing fewer lines on day one.

Where I put the effort today

If I had to sum it all up in one sentence: design modules around how they'll be composed and changed, not around how much you type today. Infrastructure as code isn't a script you run once, it's a codebase you maintain, and it suffers the same ills as any other —coupling, untested branches, contracts that break— with the added twist that here a mistake can take down a service. Fewer options, clear boundaries and outputs treated like an API are what keep a module yours six months from now instead of being the corner of the repo nobody wants to touch.

Yohangel Ramos

Written by Yohangel Ramos

Senior Fullstack Developer and Tech Lead. I build with React, Next.js, Nest.js and AWS — and I write about what I learn along the way.

Let's talk →

Keep reading

IA

AI-built startups in 2026: the real numbers behind the hype

IA

How to survive as a programmer in the AI era (without turning cynical or naive)