DRY with Modules & Locals
DRY — “Don’t Repeat Yourself” — is the principle that every piece of knowledge should have one authoritative definition. In Terraform that knowledge takes the form of resource shapes, naming rules, tag sets, and environment values; when they are copy-pasted across files they drift, and drift is how a security group rule ends up open in prod but closed in dev. The cure is not cleverness but structure: factor repeated resources into modules, hoist repeated values into locals, and parameterize the rest with variables. The goal is one place to change each fact — without making the config so abstract that the next engineer can’t read it. Everything here applies identically to Terraform 1.5+ and OpenTofu.
Where duplication comes from
Most Terraform repositories accumulate duplication in three predictable places: identical resource blocks repeated for each environment or workload, the same literal values (regions, CIDRs, account IDs) scattered through dozens of files, and tag maps re-typed on every resource. Each of these has a dedicated tool — modules, locals, and merged tag maps respectively. Reach for the lightest one that solves the problem; not every repetition needs a module.
Factor repeated resources into modules
When you find yourself writing the same group of resources more than twice — a bucket plus its policy plus its lifecycle rules, say — that cluster wants to be a module. A module is a directory of .tf files with inputs (variables) and outputs, callable from anywhere.
# modules/s3-bucket/main.tf
variable "name" { type = string }
variable "tags" { type = map(string) }
resource "aws_s3_bucket" "this" {
bucket = var.name
tags = var.tags
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
output "arn" {
value = aws_s3_bucket.this.arn
}
The behaviour — versioning on, public access fully blocked — is now defined once. Every caller gets a hardened bucket for one block:
# main.tf
module "logs" {
source = "./modules/s3-bucket"
name = "devcraftly-logs"
tags = local.common_tags
}
module "assets" {
source = "./modules/s3-bucket"
name = "devcraftly-assets"
tags = local.common_tags
}
Output:
module.logs.aws_s3_bucket.this: Creating...
module.assets.aws_s3_bucket.this: Creating...
module.logs.aws_s3_bucket.this: Creation complete after 2s [id=devcraftly-logs]
module.assets.aws_s3_bucket.this: Creation complete after 2s [id=devcraftly-assets]
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
To fix every bucket in the estate — say, add a lifecycle rule — you edit the module once and re-apply. That single source of truth is the whole point.
Hoist repeated values into locals
locals give a name to a value or expression you reference more than once within a module. They are computed at plan time and never appear as inputs, so they are perfect for derived names, constant maps, and one-line transformations.
locals {
project = "devcraftly"
env = "prod"
# one place defines the naming scheme
name_prefix = "${local.project}-${local.env}"
# shared everywhere a CIDR is needed
vpc_cidr = "10.20.0.0/16"
}
resource "aws_vpc" "main" {
cidr_block = local.vpc_cidr
tags = { Name = "${local.name_prefix}-vpc" }
}
resource "aws_db_instance" "primary" {
identifier = "${local.name_prefix}-db"
engine = "postgres"
instance_class = "db.t3.medium"
allocated_storage = 50
}
Change env once and every name updates consistently. Locals are local to their module — they do not leak into child modules — which keeps the abstraction contained.
Shared tags in one place
Tagging is the most common DRY failure: the same five tags re-typed on every resource, slowly diverging. Define them once as a local, then merge in any per-resource extras.
locals {
common_tags = {
Project = local.project
Env = local.env
ManagedBy = "terraform"
Owner = "platform-team"
}
}
resource "aws_instance" "api" {
ami = "ami-0abcdef1234567890"
instance_type = "m5.large"
tags = merge(local.common_tags, { Name = "${local.name_prefix}-api", Role = "api" })
}
For the AWS provider you can go further and apply tags to every resource automatically with default_tags, eliminating the per-resource tags argument for the shared set entirely:
provider "aws" {
region = "us-east-1"
default_tags {
tags = local.common_tags
}
}
Use
default_tagsfor organization-wide tags and reserve per-resourcetagsfor resource-specific keys likeNameorRole. Note that some resources (for example certain autoscaling group propagations) don’t inheritdefault_tags— verify in a plan rather than assuming full coverage.
Knowing when to stop
DRY is a means, not an end. A module called once, a local used in a single place, or a deeply nested abstraction that forces readers to chase definitions across five files all make code harder to maintain. The “rule of three” is a useful heuristic: tolerate the first repetition, notice the second, refactor on the third — by then the shape is clear and the abstraction will fit.
| Tool | Use when | Avoid when |
|---|---|---|
| Module | A group of resources repeats with varying inputs | A single resource used once |
| Local | A value or expression repeats within one module | The value is a caller-supplied input (use a variable) |
| Variable | A value differs per caller/environment | The value is constant everywhere (use a local) |
default_tags | Tags apply to every resource in a provider | Tags are resource-specific |
Best Practices
- Apply the rule of three: refactor on the third repetition, not the first — premature abstraction costs more than a little duplication.
- Keep modules small and single-purpose with clear inputs and outputs; a module that takes 30 variables is usually two modules.
- Define naming schemes and CIDRs once as
localsand derive everything else from them so a single edit propagates consistently. - Centralize tags with provider
default_tagsand usemergeonly for genuinely resource-specific keys. - Prefer readability over maximal DRY — if an abstraction forces the reader to open three files to understand one resource, inline it.
- Pin module versions when sourcing from a registry or Git so DRY does not become a silent global change across environments.
- The same module/locals/
default_tagspatterns work unchanged under OpenTofu; keep shared code tool-agnostic.