Skip to content
Infrastructure as Code iac patterns 5 min read

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_tags for organization-wide tags and reserve per-resource tags for resource-specific keys like Name or Role. Note that some resources (for example certain autoscaling group propagations) don’t inherit default_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.

ToolUse whenAvoid when
ModuleA group of resources repeats with varying inputsA single resource used once
LocalA value or expression repeats within one moduleThe value is a caller-supplied input (use a variable)
VariableA value differs per caller/environmentThe value is constant everywhere (use a local)
default_tagsTags apply to every resource in a providerTags 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 locals and derive everything else from them so a single edit propagates consistently.
  • Centralize tags with provider default_tags and use merge only 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_tags patterns work unchanged under OpenTofu; keep shared code tool-agnostic.
Last updated June 14, 2026
Was this helpful?