TL;DR

  • Terraform Locals keep modules consistent by centralizing naming rules, tag logic, and environment defaults, reducing the chance of configuration drift across resources.
  • They’re best used for derived logic; caller inputs belong in variables, and provider-controlled values should come from data sources to maintain clear module boundaries.
  • Locals improve readability when they replace repeated or noisy expressions, but they add overhead when applied to trivial or single-use values.
  • Firefly complements this by generating Terraform that already follows a locals-first structure, codifying unmanaged cloud resources and extracting shared fields into locals, and checking whether the values produced by those locals still match the live cloud.
  • This keeps both the Terraform codebase and the underlying infrastructure aligned, even in large environments with multiple teams, regions, and IaC sources.

Once a Terraform project spans multiple environments and modules, repeated values start showing up everywhere: names, tags, region mappings, instance sizing, and other configuration details that should never drift. Pulling those into locals usually helps keep things consistent. The trouble starts when too much logic moves into locals.tf, and the module becomes harder to read because the important parts of a resource are now hidden behind lookups.

A Terraform user on Reddit ran straight into this problem. They began centralizing repeated values into locals, which cleaned up the code visually, but reviewing changes became slower. A simple IAM attachment no longer showed which policy was being referenced; the reviewer had to open another file, find the local, and mentally resolve it before continuing. Their question was practical: Is there a best practice for how much logic should live in locals, and how do you strike a reasonable balance?

That balance depends on understanding how locals actually behave, where they reduce noise, and where they make a module harder to reason about. The sections ahead break down the mechanics of locals, common patterns where they add clarity, situations where they obscure intent, and how tools like Firefly surface the final resolved values and detect drift when cloud resources no longer match what the locals compute.

What Terraform Locals Are

Locals in Terraform are named expressions evaluated inside a module during the plan phase. They give you a way to compute a value once and reuse it across multiple resources without duplicating the same logic.

A typical locals block looks like this:

locals {
  service_name = "billing-api"
  size_map = {
    dev  = "t3.small"
    prod = "m5.large"
  }
}

And you reference them with:

instance_type = local.size_map[var.env]

Locals exist only within the module that defines them. If another module needs the value, you pass it explicitly as an input.

What Locals Can Contain

Locals can hold any Terraform expression: maps, lists, strings, function calls, variable references, and even attributes from data sources or resources. This makes them a good place for:

  • naming rules
  • environment mappings
  • computed flags or defaults
  • any logic that repeats across several resources

The key point: locals are for logic, not configuration. Values that users must control should remain variables.

How Terraform Evaluates Locals

Terraform evaluates locals early in the plan. They’re deterministic for that run and don’t persist anywhere. Terraform doesn’t store locals in state; only the final resolved resource attributes end up there.

If you change a local and it affects a resource field, Terraform sees it as a normal diff on that field. The local itself is never part of state tracking.

Constraints to Keep in Mind

The only hard rule is to avoid cycles. Terraform won’t accept locals that reference each other in a loop:

locals {
  a = local.b
  b = local.a
}

Terraform fails immediately because it can’t resolve the evaluation order.

When Locals Are Useful (and When They Aren’t)

Locals work best when:

  • a value is used more than once,
  • the inline expression would make a resource block noisy,
  • you want one place to review the logic behind a decision

However, putting single-use or trivial values into locals slows down navigation and hides intent. Locals should reduce mental overhead, not increase it. The goal isn’t to move everything into locals.tf; it’s to centralize logic that genuinely benefits from being defined once.

Why Locals Matter in Terraform-based Deployments

Locals become valuable in real Terraform projects for one main reason: they let you centralize logic that would otherwise be repeated across several resources. In small modules, this doesn’t matter much, but once you’re managing environments, naming rules, and shared tags, repeating the same expression in five places becomes a liability. Locals remove that repetition and make the configuration easier to maintain.

Cutting Down Repetition the Right Way

When a module creates multiple related resources, such as VPCs, subnets, security groups, and load balancers, they usually share a naming pattern built from variables such as project_name and environment. If each resource builds that string manually, the interpolation gets repeated everywhere.

Using a locals block centralises that logic:

locals {
  name_suffix = "${var.project_name}-${var.environment}"
}

This ensures naming stays consistent across all resources without copy-paste duplication. When the naming rule changes, it’s updated in one place instead of hunting through the entire module. In real enterprise setups, this is what prevents drift: manually constructed names, tags, and labels tend to diverge over time, while locals enforce a single source of truth.

Making Derived Values Explicit

Locals help document how a value is built. Reviewers see a clean resource block instead of long inline expressions. For example, the tutorial uses locals to define required tags and merge them with user-supplied tags:

locals {
  required_tags = {
    project     = var.project_name
    environment = var.environment
  }
  tags = merge(var.resource_tags, local.required_tags)
}

This pattern makes it obvious what tags Terraform guarantees and which ones are optional. When this logic is in line across resources, updates are harder to track, and consistency is usually lost.

Keeping Behavior Predictable Across Runs

Locals don’t behave like program variables that change during execution. They’re evaluated once during planning, and the value stays fixed for the duration of that run. This matters when building modules for teams: locals won’t introduce run-to-run variance, and they don’t let consumers override internal behavior accidentally. The only way a local value changes is when you modify the code.

Reducing Noise in Resource Blocks

A resource block filled with string formatting, lookups, and conditionals slows down reviews. Moving that logic into locals doesn’t “hide” it; reviewers still see it, but in one consolidated location where the intent is clearer. The resource then shows the outcome, not the mechanics:

tags = local.tags
name = "lb-${random_string.lb_id.result}-${local.name_suffix}"

This makes the module easier to navigate, especially when multiple teams maintain it.

Keeping Environment Differences Contained

Real-world Terraform projects eventually need different behavior for dev/stage/prod. Without locals, those rules scatter through resources. With locals, the environment logic sits in one place, making it clear what changes between environments and what stays consistent.

For example, switching the environment from dev to prod in the tutorial forces Terraform to rebuild resources because the name_suffix and tags change. Locals make this behavior explicit and predictable.

Structure of a Locals Block

A locals block is straightforward: it’s a map of names to expressions. Terraform evaluates each expression when planning the module, and every value in the block becomes available under the local.* namespace.

Basic Pattern

A typical locals block looks like this:

locals {
  service_name = "billing-api"

  instance_sizes = {
    dev  = "t3.small"
    prod = "m5.large"
  }

  enable_logs = var.environment == "prod"
}

Each entry in the block is an independent expression. Terraform doesn’t care about their order; it builds an evaluation graph and resolves dependencies automatically.You reference them using:

name           = local.service_name
instance_type  = local.instance_sizes[var.environment]
logging        = local.enable_logs

Supported Expression Types

Locals can use anything Terraform can evaluate:

  • Strings, numbers, booleans
  • Lists and maps
  • Results of functions (merge, format, lookup, etc.)
  • Variable values
  • Data source attributes
  • Resource attributes within the same module

This flexibility is the reason locals often hold derived or computed logic that you don’t want repeated in resource blocks.

Examples You See in Real Modules

Simple value

locals {
  region = var.aws_region
}

Shared tag map

locals {
  tags = {
    project     = var.project
    environment = var.environment
  }
}

Workspace or environment-based logic

locals {
  db_size = var.environment == "prod" ? "db.m5.large" : "db.t3.small"
}

String formatting helpers

locals {
  name_prefix = format("%s-%s", var.project, var.environment)
}

Loop-derived structures

locals {
  subnet_cidrs = [for i in range(3) : cidrsubnet(var.vpc_cidr, 4, i)]
}

These patterns show up repeatedly in production Terraform, naming schemes, instance sizing, derived tags, environment toggles, subnet math, and so on.

How Terraform Evaluates the Block

Terraform evaluates locals during planning, before resource arguments are finalized. They behave as constants for that run. Terraform does not store locals in the state file; only the resolved values applied to resources end up there.

Internally, locals are meant to simplify the configuration, not introduce another layer of runtime behavior. That’s why they’re deterministic and based purely on expressions available at plan time.

Practical Use Cases for Locals

Locals are most useful when they replace logic that would otherwise be repeated or scattered across resources. Below are the patterns that show up consistently in mature Terraform codebases.

Environment Standardization

Projects almost always need different defaults for dev, stage, and prod. Without locals, these rules get duplicated across modules and eventually drift. Locals let you centralize that behavior.

locals {
  instance_type = var.environment == "prod" ? "m5.large" : "t3.small"
  region        = var.environment == "dev"  ? "us-west-2" : var.default_region
  name_prefix   = "${var.project}-${var.environment}"
}

This keeps environmental differences in one place instead of being buried inside multiple resources. When you update the policy for an environment, you update it once.

Centralized Tagging

Tagging logic is one of the first things that spreads across a module. When teams grow, inconsistent tags become a real problem, cost allocation breaks, security filters miss resources, and audits get noisy.

Locals let you build a consistent tag map and apply it everywhere:

locals {
  required_tags = {
    project     = var.project
    environment = var.environment
  }

  tags = merge(local.required_tags, var.additional_tags)
}

Every resource then uses local.tags, eliminating tag drift entirely.

Wrapping Complex Expressions

Some Terraform expressions are correct but too noisy to inline, such as nested maps, long conditionals, name formatting, AMI lookups, and cluster sizing logic. Putting that into locals keeps the resource blocks readable.

Long conditional example

locals {
  enable_backups = (
    var.environment == "prod" &&
    var.feature_flags.backups &&
    length(var.subnets) > 1
  )
}

String formatting

locals {
  dns_name = format("%s-%s.internal", var.service, var.environment)
}

AMI selection

locals {
  ami_id = data.aws_ami.latest.id
}

Locals isolate the logic so reviewers can understand it once instead of parsing it repeatedly inside resource definitions.

Reducing Duplication in Large Repositories

As modules multiply, subtle differences begin to show up: slightly different naming rules, slightly different tag sets, slightly different subnet math. These differences are usually unintentional and introduce configuration drift before anything even hits the cloud.

Locals give each module an internal “source of truth”:

locals {
  naming = {
    svc  = var.service
    env  = var.environment
    uuid = random_id.deploy.hex
  }
}

Resources are then pulled from local.naming instead of reassembling their own versions. This creates a consistent baseline that survives code reviews and team changes.

Locals vs Variables vs Data Sources

As soon as a module grows beyond a few resources, you start seeing the same question come up: where should this value actually live?. Based on the workflow shown in the snapshot below:

Some values need to come from the caller, some belong inside the module as computed logic, and some have to be fetched from the provider. If you don’t draw clear boundaries early, the module becomes inconsistent, a mix of variables used as internal logic, locals used as configuration, and data sources used for things that should’ve been passed in.

This section lays out the correct separation so the module stays predictable as it grows.

Variables: values the caller must control

Variables represent inputs that come from outside the module, CLI flags, Terraform Cloud workspaces, CI pipelines, parent modules, or .tfvars files.

Use a variable when:

  • the value differs per deployment,
  • the caller must choose the value,
  • or the module should not hardcode a decision.

Example:

variable "environment" {}
variable "instance_count" {}
variable "project" {}

A variable is not a place for internal logic. It defines the module’s API surface.

Locals: values the module derives

Locals compute logic inside the module based on what the module already knows: variables, resource attributes, or data source results.

Use a local when:

  • the value is derived,
  • it appears more than once,
  • or the raw expression would clutter the resource block.

Example:

locals {
  name_prefix = "${var.project}-${var.environment}"
  tags        = merge(local.base_tags, var.extra_tags)
}

Locals represent internal rules. They are not intended to be configured by the caller or fetched externally.

Data Sources: values resolved from the provider

Data sources query the provider to obtain information that exists in the cloud, AMIs, VPC IDs, subnets, security groups, hosted zones, etc.

Use a data source when the value should come from AWS, GCP, Azure, or another provider, not from your own variables.

Example:

data "aws_ami" "latest" {
  owners      = ["amazon"]
  most_recent = true
  filters = [{
    name   = "name"
    values = ["amzn2-ami-hvm-*"]
  }]
}

If the cloud controls the value, it belongs in a data source.

A clean decision rule

This simple boundary prevents confusion in large codebases:

  • Variables: caller chooses the value
  • Locals: module computes the value
  • Data sources: the provider supplies the value

If a value doesn’t clearly belong to one of these categories, the module’s design usually needs tightening.

Best Practices for Terraform Locals

  • Use direct, descriptive names: Locals should tell you exactly what they represent. Names like base_tags, name_prefix, or instance_map are clear. Avoid vague labels that force the reader to inspect the expression to understand the purpose.
  • Group related logic together: Keep naming rules in one block, tag logic in another, environment rules in another. This keeps the internal structure of the module obvious instead of scattering logic across multiple locals blocks.
  • Introduce a local only when it removes real duplication:  If the expression appears once or is already readable inline, creating a local just adds another lookup. Reserve locals for expressions used multiple times or for logic that makes a resource block noisy.
  • Keep locals purely deterministic: Locals should not depend on anything that changes across runs. No randomness, no timestamps, no external lookups. They should produce the same output every time Terraform plans the module when inputs are unchanged.
  • Don’t treat locals as configuration: If a value needs to be selected by the caller (environment, CIDRs, sizing, feature toggles), it belongs in a variable. Locals should represent derived behavior, not user-controlled settings.
  • Separate inputs and logic visually: Place variables and locals in distinct sections of the file so it’s easy to see where configuration ends and internal module logic begins. This prevents accidental overlap between what the module exposes and what it computes.
  • Avoid chaining locals unnecessarily: One local depending on another is fine; deep chains are not. When locals start referencing other locals several layers deep, the logic becomes harder to follow and harder to debug.
  • Use locals to standardize behavior, not to hide it: If a local makes it harder for a reviewer to understand what a resource is doing, it’s the wrong place for that logic. Locals should reduce mental overhead, not increase it.

Well-structured locals keep the module’s logic consistent, but Terraform stops at configuration and state management. It doesn’t verify whether the cloud still matches the values those locals produce or whether teams are applying those rules consistently across environments. That gap is where Firefly becomes useful, because it works with the final resolved attributes generated from locals and validates them against the live infrastructure.

How Firefly Complements Terraform Locals

Terraform locals help keep a module consistent, but Terraform stops tracking those values once they’re written into the state file. Firefly fills the operational gaps around discovery, codification, refactoring, visibility, and drift detection. The result is a workflow where locals aren’t just a coding pattern; they become a consistent structure applied across both new IaC and unmanaged cloud resources.

Generating Terraform That Uses Locals From the Start

When generating a new Terraform, Firefly produces a locals-driven structure instead of a flat resource definition. That gives you a clean module layout without manually refactoring.

Firefly-generated locals.tf:

locals {
  instance_settings = {
    name         = "terraform-vm-instance"
    machine_type = "e2-medium"
    zone         = var.zone
  }

  disk_config = {
    boot_disk = {
      initialize_params = {
        image = "debian-cloud/debian-11"
        size  = 20
        type  = "pd-standard"
      }
      auto_delete = true
    }
    additional_disk = {
      device_name = "data-disk"
      size        = 50
      type        = "pd-ssd"
      auto_delete = false
    }
  }

  network_tags = [
    "web-server",
    "ssh-enabled",
    "http-server",
    "https-server"
  ]

  labels = {
    environment = var.environment
    managed_by  = "terraform"
    application = "web-app"
    team        = "devops"
    cost_center = "engineering"
  }

  metadata = {
    startup-script = <<-EOF
      #!/bin/bash
      ...
    EOF
    enable-oslogin     = "true"
    block-project-keys = "false"
    serial-port-enable = "true"
  }

  service_account_scopes = [
    "https://www.googleapis.com/auth/cloud-platform",
    "https://www.googleapis.com/auth/compute",
    ...
  ]
}

Locals give the configuration a single internal source of truth, for instance, settings, disk parameters, tags, and metadata, which keeps those values consistent across the module. By moving that structure into a locals block, the resource definition stays readable instead of being filled with repeated maps and inline expressions. It also means teams start with a maintainable layout immediately, rather than having to refactor hardcoded values after the first few iterations of the code.

Turning Unmanaged Cloud Resources Into Terraform, Then Refactoring Them Into Locals

Firefly’s Inventory view shows cloud assets and their IaC status: managed, unmanaged, or drifted.

Selecting an unmanaged resource: 

Codify generates the Terraform import + resource. Initially, Firefly outputs a direct, literal translation of the cloud resource:

Here’s the codified output before adding locals:

import {
  to = google_compute_subnetwork.vm-base-network
  id = "projects/sound-habitat-462410-m4/regions/asia-southeast3/subnetworks/vm-base-network"
}

resource "google_compute_subnetwork" "vm-base-network" {
  enable_flow_logs         = false
  ip_cidr_range            = "10.232.0.0/20"
  name                     = "vm-base-network"
  network                  = "https://www.googleapis.com/compute/v1/projects/sound-habitat-462410-m4/global/networks/vm-base-network"
  private_ip_google_access = false
  project                  = "sound-habitat-462410-m4"
  purpose                  = "PRIVATE"
  region                   = "asia-southeast3"
}

This is correct, but not maintainable; everything is repeated. Now in the codification editor, you can convert hardcoded fields into locals in one step, where the AI assistant thinkerbell in the Infrastructure Codification view is prompted to “Add locals block for common variables”, as shown in the snapshot below:

Here’s the refactored version with locals by extracting common fields:

locals {
  project      = "sound-habitat-462410-m4"
  region       = "asia-southeast3"
  network_name = "vm-base-network"
  cidr_range   = "10.232.0.0/20"
}

resource "google_compute_subnetwork" "vm-base-network" {
  enable_flow_logs         = false
  ip_cidr_range            = local.cidr_range
  name                     = local.network_name
  network                  = "https://www.googleapis.com/compute/v1/projects/${local.project}/global/networks/${local.network_name}"
  private_ip_google_access = false
  project                  = local.project
  purpose                  = "PRIVATE"
  region                   = local.region
}

Refactoring the codified resource into locals turns a raw API dump into maintainable Terraform. The repeated values are centralized, the resource block becomes clearer, and the structure now matches how a real module is normally written. Instead of scattering project IDs, regions, names, and CIDR values across the resource, everything is pulled into a single locals block that defines the internal logic for the configuration.

Without Firefly, this would require a manual workflow: inspecting the cloud resource through the console or CLI, copying each attribute into Terraform, running the import command, identifying which fields should become locals, and rewriting the resource to use local.* references, checking that no attributes were missed, and then packaging the result into a PR. Firefly collapses all of that into a single flow and outputs PR-ready IaC that follows the same standards as your existing modules.

Tracking Drift and IaC Coverage for Resources Built on Locals

Once Terraform manages the resource and locals define internal logic, Terraform won’t detect deviations unless someone manually runs terraform plan. Firefly continuously compares the values stored in Terraform state with the live cloud attributes and flags any mismatch, including attributes originally produced by locals, such as names, tags, CIDRs, or instance settings.

Firefly’s inventory and codification status also give teams a clear view of IaC coverage: which resources are fully managed, which still need codification, and where cleanup or refactoring is required. This keeps Terraform and the real cloud environment aligned long after the initial deployment.

FAQs

What is the difference between locals and tfvars in Terraform?

Locals are computed values used inside a module to reduce repetition, simplify expressions, and make configuration easier to read. They never accept external input. Tfvars files are input variable definitions that let you pass environment-specific values into modules without hardcoding them.

What is a local module in Terraform?

A local module is a reusable set of Terraform resources stored in your repository, referenced using a relative path like source = "./modules/vpc". It helps you standardize patterns across environments without duplicating code. Local modules behave the same as remote modules; the only difference is the source location.

Can you have multiple locals blocks in Terraform?

Yes, Terraform allows multiple locals blocks in a module. Terraform automatically merges them into a single logical locals namespace during evaluation. Multiple blocks are useful when you want to group related computed values or keep configurations organized.

What is null in Terraform?

null represents the absence of a value in Terraform. When used, Terraform behaves as if the argument wasn’t set, allowing defaults or conditional logic to take over. It’s commonly used in dynamic expressions, optional fields, and to disable resource arguments cleanly.

‍