Skip to content
Innovialabs

Terraform Modules: How to Structure Reusable Infrastructure

Practice4 min

In short

A good Terraform module wraps one coherent piece of infrastructure behind a small set of typed inputs and useful outputs, is versioned and pinned, and is called from thin per-environment root configurations with separate state.

The short answer

A module in Terraform is just a folder of .tf files. Every configuration is already a root module; reusable modules are folders you call with a module block.

A good module:

  • describes one coherent thing: a network, a database with its backups, a service with its load balancer;
  • exposes a few typed inputs with sensible defaults;
  • returns outputs that other parts of the stack actually need;
  • is versioned, so environments upgrade on purpose, not by accident.

Environments (dev, staging, prod) are thin root configurations that call the same modules with different values and keep separate state.

Inputs, outputs and the module contract

# modules/postgres/variables.tf
variable "name" {
  type        = string
  description = "Instance name prefix"
}

variable "instance_size" {
  type    = string
  default = "small"
}

variable "backup_retention_days" {
  type    = number
  default = 7
  validation {
    condition     = var.backup_retention_days >= 1
    error_message = "Retention must be at least one day."
  }
}

# modules/postgres/outputs.tf
output "endpoint" {
  value = aws_db_instance.this.address
}

output "password_secret_arn" {
  value     = aws_secretsmanager_secret.db.arn
  sensitive = true
}

Treat variables and outputs as a public API:

  • Use explicit type and description; add validation for values that are easy to get wrong.
  • Prefer one object variable over a dozen loosely related ones only when the fields really belong together.
  • Mark secrets sensitive = true, and remember they still end up in state, so protect the state backend.
  • Do not configure provider blocks inside reusable modules. Let the caller pass providers in.

Versioning

Calling a module from a Git repository or a registry lets you pin a version:

module "db" {
  source = "git::https://github.com/acme/terraform-modules.git//postgres?ref=v1.3.0"

  name                  = "shop-prod"
  instance_size         = "large"
  backup_retention_days = 14
}
  • Tag releases with semantic versions: breaking changes to inputs or resource addresses mean a new major version.
  • Upgrade dev first, then staging, then prod, reviewing terraform plan each time.
  • When you rename resources inside a module, add moved blocks so Terraform updates addresses instead of destroying and recreating.
  • Pin provider versions with required_providers and commit the lock file.

A sensible repository layout

infra/
  modules/
    network/
    postgres/
    app-service/
  envs/
    dev/
      main.tf
      backend.tf
      terraform.tfvars
    staging/
    prod/
  • Each folder in envs/ is its own root with its own state and backend config. A mistake in dev cannot touch prod state.
  • Differences between environments live in variables, not in copy-pasted resources.
  • Split large environments into a few stacks (network, data, apps) when plans become slow or risky. Pass values between them through outputs and data sources.
  • Workspaces can work for near-identical environments, but separate folders make differences and access control more visible.

Using registry modules safely

Public registry modules save time, but you run someone else's code with your cloud credentials.

  • Prefer modules from verified or well-maintained publishers with active issues and releases.
  • Always pin an exact version; never rely on the latest.
  • Read the source and the plan: what resources and IAM permissions does it create?
  • Check that defaults suit you, especially encryption, public access and logging.
  • For critical infrastructure, consider forking or mirroring the module into your organization.

Anti-patterns

  • Over-abstracted modules: a "universal" module with dozens of flags that can build any resource. It is harder to read than plain resources and every change risks every user.
  • Wrapper modules that only rename one resource's arguments and add nothing.
  • Deep nesting: modules calling modules several levels down make plans hard to understand.
  • Copy-pasted environments that drift apart over time.
  • Hardcoded values like region, account IDs or CIDRs inside a reusable module.
  • One giant state for everything, so every apply touches the whole company.

A good rule: write a module when you have at least two real uses for it, or when it encodes a standard that must be applied consistently.

FAQ

When the same group of resources appears in several places, or when you need to enforce a standard such as encryption and tagging. A single one-off resource rarely needs a module.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.