Designing Better Terraform Modules Through Software Principles
Terraform modules have interfaces, dependencies, and versioning just like software libraries. Thinking about them that way changes how you design infrastructure.
Most engineers first learn Terraform by creating a few resources, defining some variables, and running terraform apply. That’s enough to provision infrastructure, and it’s a perfectly reasonable place to start.
Eventually, though, infrastructure grows beyond a single environment or a single maintainer. That’s where questions start to change.
- Which values belong in a module’s interface?
- Which details should stay internal?
- How should modules depend on each other?
- What makes a module reusable?
- When is changing a module a breaking change?
These are software design questions. Terraform happens to describe infrastructure, but its modules have many of the same characteristics as software libraries: they expose interfaces, encapsulate implementation details, evolve over time, and depend on one another.
Looking at Terraform through that lens has changed how I design infrastructure. To make the discussion concrete, I’ll use a fictional company throughout this article.
The Case: Mira Health
Mira Health is a small bootstrapped SaaS building appointment scheduling software for independent clinics. To keep costs low, everything runs on a single Ubuntu server: 28 CPU cores, 64 GB RAM, 1 TB NVMe, running libvirt/KVM.
The current infrastructure works, but only because the people who built it remember how. Virtual machines were created manually in virt-manager, firewall rules were added interactively with iptables, and networks evolved organically. Nobody is entirely confident they could rebuild the server from scratch and end up with the same result.
Their next goal is straightforward: a reverse proxy, application server, PostgreSQL database, monitoring stack, and SSH jump host with an identical staging environment managed from the same Terraform codebase. The infrastructure itself isn’t especially complicated. The interesting part is how to organize the Terraform.
A Module Is a Library
When I design a Terraform module, I try to think of it as a software library rather than a collection of resources. A library has a public interface, private implementation, defined responsibilities, and known consumers. A Terraform module has exactly the same things.
Its public interface is defined by variables.tf and outputs.tf. Everything else is implementation.
Consider a networking module like this:
module "network" {
source = "../modules/network"
subnet_count = 3
subnet_mask = 24
nat_gateway_count = 2
route_table_strategy = "per-subnet"
bridge_mtu = 1500
dhcp_range_start = "10.10.10.50"
# ...
}
There’s nothing technically wrong with it. The question is whether all of those values really belong in the module’s public interface. If callers need to understand bridge MTUs, DHCP ranges, and routing strategies simply to create a network, then implementation details have become part of the API. That makes future refactoring harder because every input becomes something callers may rely on.
For Mira Health, the networking module only needs two pieces of information:
module "network" {
source = "../../modules/network"
environment = "production"
cidr_prefix = "10.10"
}
Everything else stays inside the module: the tier numbering, the bridge configuration, the DHCP ranges, the routing layout. Those decisions can evolve without affecting callers because they are implementation details. The interface becomes much easier to understand.
Designing the Interface First
Instead of writing resources first, I prefer to define the interface before implementing anything.
variable "environment" {
type = string
validation {
condition = contains(
["production", "staging"],
var.environment
)
error_message = "Must be production or staging."
}
}
variable "cidr_prefix" {
type = string
validation {
condition = can(
regex("^\\d{1,3}\\.\\d{1,3}$", var.cidr_prefix)
)
error_message = "Expected two octets like 10.10."
}
}
Those two variables represent the only things that actually vary between deployments. Everything else can remain private.
locals {
tier_offsets = {
edge = 10
app = 20
db = 30
mgmt = 40
}
}
Consumers never need to know those numbers exist.
Outputs Are Also Part of the Interface
Inputs often receive most of the design attention, but outputs deserve the same treatment. If another module only needs the network name, gateway address, and CIDR block, those are the only things the module should expose.
output "tiers" {
value = {
for tier, offset in local.tier_offsets : tier => {
network_name = libvirt_network.tiers[tier].name
cidr = "${var.cidr_prefix}.${offset}.0/24"
gateway_ip = "${var.cidr_prefix}.${offset}.1"
host_ip = "${var.cidr_prefix}.${offset}.2"
}
}
}
Consumers can now write module.network.tiers["app"].network_name without knowing how that value was produced. Just like in application code, every public output becomes something consumers may depend on. Keeping the interface small makes future changes easier.
Infrastructure Is a Dependency Graph
Terraform evaluates resources as a directed acyclic graph. Rather than thinking about individual resources, I find it more useful to think about how information flows between modules.
For Mira Health, the dependency graph looks like this:
network
│
├────────────► firewall
│
└────────────► cloudinit
│
▼
vm
The interesting thing here is that the graph forms naturally. The firewall depends on the network because it consumes module.network.tiers. The VM depends on cloud-init because it consumes rendered configuration. No explicit sequencing is necessary.
module "firewall" {
source = "../../modules/firewall"
tiers = module.network.tiers
}
That single reference creates the dependency. I generally see depends_on as something to use when data flow cannot express the relationship. Most module dependencies can be represented simply by passing values between modules. Thinking about Terraform this way naturally leads toward loosely coupled modules with clearer boundaries.
Composition Instead of Large Modules
Terraform modules benefit from the same single-responsibility thinking that we apply to application code. For Mira Health, I split responsibilities into five modules.
| Module | Responsibility |
|---|---|
network | Create isolated libvirt networks |
firewall | Configure routing and security policy |
cloudinit | Render VM bootstrap configuration |
vm | Create one virtual machine |
envs/<environment> | Compose everything together |
The environment directory becomes the composition root:
envs/
├── production/
│ └── main.tf
└── staging/
└── main.tf
Its job isn’t to create resources directly. Its job is to wire modules together. That also makes reuse straightforward. The VM module can be instantiated multiple times:
module "edge" {
source = "../../modules/vm"
name = "edge"
}
module "app" {
source = "../../modules/vm"
name = "app"
}
module "db" {
source = "../../modules/vm"
name = "db"
}
Each instance shares the same implementation while receiving different configuration.
Designing Security on Bare Metal
Cloud platforms provide abstractions such as security groups and managed networking. Running directly on libvirt means those abstractions need to be designed explicitly.
For Mira Health, I chose to isolate each application tier into its own libvirt network. Traffic between those networks passes through a dedicated VyOS virtual machine, making the firewall module responsible for all inter-tier communication.
Instead of embedding firewall rules into every VM, the policy is represented as data:
variable "allowed_flows" {
type = list(object({
from_tier = string
to_tier = string
protocol = string
port = number
}))
default = [
{ from_tier = "lan", to_tier = "edge", protocol = "tcp", port = 443 },
{ from_tier = "edge", to_tier = "app", protocol = "tcp", port = 8080 },
{ from_tier = "app", to_tier = "db", protocol = "tcp", port = 5432 },
]
}
The firewall module renders those rules into VyOS configuration. Adding a new permitted flow becomes a small change to a single data structure rather than a search across multiple VM definitions. The same design could be implemented differently on AWS, Azure, or GCP using their native networking features—the underlying principle remains the same: centralize policy rather than distributing it across consumers.
Versioning Still Matters
Terraform modules evolve over time. Changing an input, removing an output, or changing resource behavior can affect downstream consumers. That makes versioning just as important as it is for application libraries. Provider versions should be pinned:
terraform {
required_version = ">= 1.5"
required_providers {
libvirt = {
source = "dmacvicar/libvirt"
version = "~> 0.8"
}
cloudinit = {
source = "hashicorp/cloudinit"
version = "~> 2.3"
}
}
}
I also commit .terraform.lock.hcl and pin VM images to explicit releases instead of relying on latest. Treating infrastructure dependencies consistently makes deployments more predictable over time.
Where the Analogy Stops
Thinking about Terraform as software design is useful, but the comparison isn’t perfect. Infrastructure introduces constraints that application code usually doesn’t.
State
Terraform maintains a state file that maps configuration to real infrastructure. That file is shared, mutable, and often sensitive, as it needs locking, backups, and careful handling. Most application libraries don’t have an equivalent concern.
Refactoring
Renaming a function in application code is usually harmless. Renaming a Terraform resource may cause Terraform to destroy and recreate infrastructure. When reorganizing modules, moved blocks help Terraform understand that a resource has changed location rather than identity:
moved {
from = libvirt_domain.db
to = module.db.libvirt_domain.this
}
For stateful resources such as databases, I also consider lifecycle { prevent_destroy = true } before any structural refactoring.
Apply Has Real-World Consequences
Running terraform apply changes real infrastructure. It provisions machines, creates disks, changes networks, deletes resources. That makes reviewing the execution plan an important part of the workflow. The plan isn’t just a preview; it’s an explanation of what Terraform believes should happen.
The Result
Once the modules are designed around stable interfaces, creating another environment becomes mostly an exercise in composition:
module "network" {
source = "../../modules/network"
environment = "staging"
cidr_prefix = "10.20"
}
module "firewall" {
source = "../../modules/firewall"
environment = "staging"
tiers = module.network.tiers
lan_bridge = var.lan_bridge
base_image = var.vyos_image
}
Production and staging share the same modules; only the values change. Each environment maintains its own Terraform state, address ranges remain isolated, and the infrastructure evolves through the same set of reusable building blocks.
Practical Guidelines
If I’m starting a new Terraform codebase, these are the habits I try to follow:
- Design the module interface before writing resources.
- Keep variables focused on genuine points of variation.
- Expose only the outputs other modules actually need.
- Organize modules around responsibilities rather than resource types.
- Prefer expressing dependencies through data flow instead of
depends_on. - Pin provider versions, module versions, and base images.
- Read every execution plan before applying it.
None of these practices are unique to Terraform. They’re familiar ideas from software engineering applied to infrastructure.
Closing
Terraform is often introduced as a tool for declaring infrastructure. That’s true, but it leaves out an important perspective. As infrastructure grows, the challenge shifts from writing resources to designing systems.
Modules become reusable components. Variables and outputs become interfaces. Data flow defines dependencies. Versioning and compatibility become ongoing concerns.
Thinking about Terraform as software design doesn’t change the language itself. It changes the questions you ask while writing it. And in my experience, those questions lead to infrastructure that’s easier to understand, evolve, and reuse.
The Mira Health case study is fictional. The examples target a single Ubuntu host using libvirt/KVM and the dmacvicar/libvirt provider, but the design principles apply more broadly to Terraform module design.