Skip to main content
RunBook Academy

← All labs in Terraform

Lab · intermediate · ~40 min

Lab: Using Locals for Readability and DRY

C · Simulation

Objectives

  • Build a locals block that derives a for_each map, a name prefix and a shared label set from three inputs
  • Read every local with terraform console, which is the only way to see one
  • Demonstrate that a local cannot be set from the command line and appears in neither the state file nor the plan JSON
  • Measure the blast radius of a local: one boolean flip replacing two resources, one string change replacing all three
  • Audit the module for single-use locals and inline the one that is only a rename, proving the refactor with an empty plan

Prerequisites

Objective

By the end of this lab you will have built a locals block that does the three jobs locals are actually for — deriving a for_each map from a list of inputs, computing a naming convention, and holding a label set that several resources share — and you will have measured what it costs.

You will also have established, by command rather than by assertion, three facts about locals that decide how you use them: a local cannot be set from outside the configuration, a local appears in neither the state file nor the plan JSON, and every value a local touches appears in both.

Architecture

One directory, three inputs, four locals, three files rendered.

tf-locals-lab/
├── main.tf              variables, locals, two resource blocks
├── terraform.tfvars     environment, owner, and a list of two services
├── audit-locals.sh      written in Task 7
│
├── staging-eu-api.conf         from local_file.service["api"]
├── staging-eu-worker.conf      from local_file.service["worker"]
└── staging-eu-manifest.conf    from local_file.manifest

The data flows one way, and every arrow is a local:

var.environment ──┬──> local.name_prefix ────────> both resources' filenames
                  └──> local.common_labels ──┬───> local.service_map.*.labels
var.owner ───────────> local.common_labels ──┘
var.services ────────> local.service_map ─────┬──> local_file.service (for_each)
                                              └──> local_file.manifest (totals)

local_file stands in for anything with a name and a label set: a VM, a bucket, a DNS record, a Kubernetes namespace. The rendered files let you read the result of every expression with cat instead of a cloud console.

Requirements

  • A Linux or macOS workstation with shell access and a writable $HOME.
  • Terraform 1.9.x or later. Every output below was captured on Terraform v1.9.8 with hashicorp/local v2.9.0.
  • jq, for reading the state and plan JSON in Task 5.
  • Outbound HTTPS to registry.terraform.io for the first terraform init.
  • No cloud account, no credentials, no cost.

Scenario

A module renders one config file per service, plus a manifest summarising all of them. It started with two services and the values inlined. It now has eleven, the environment name appears in nine places, and the same three labels are copy-pasted into every resource.

Somebody proposes moving everything into locals. Somebody else has been on a team where that produced an 800-line module nobody could read, and says so.

They are both right, and the argument is not settleable in the abstract. This lab builds the version that works, then applies the test that tells you which locals earned their place.

Tasks

Task 1: Scaffold

LAB="$HOME/tf-locals-lab"
mkdir -p "$LAB"
cd "$LAB"

terraform version

Task 2: Write the configuration

main.tf — three inputs, four locals, two resource blocks:

terraform {
  required_version = ">= 1.9.0"

  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
  }
}

variable "environment" {
  description = "Environment this stack belongs to."
  type        = string
}

variable "owner" {
  description = "Team accountable for these resources."
  type        = string
}

variable "services" {
  description = "Services to render a config file for."
  type = list(object({
    name     = string
    port     = number
    critical = bool
  }))
}

locals {
  env = var.environment

  name_prefix = "${var.environment}-eu"

  common_labels = {
    environment = local.env
    owner       = var.owner
    managed_by  = "terraform"
  }

  service_map = {
    for s in var.services : s.name => {
      port           = s.port
      retention_days = s.critical ? 30 : 7
      labels = merge(local.common_labels, {
        service  = s.name
        critical = tostring(s.critical)
      })
    }
  }
}

resource "local_file" "service" {
  for_each = local.service_map

  filename = "${path.module}/${local.name_prefix}-${each.key}.conf"
  content  = <<-EOT
    [service]
    name = ${each.key}
    port = ${each.value.port}
    retention_days = ${each.value.retention_days}

    [labels]
    ${join("\n", [for k, v in each.value.labels : "${k} = ${v}"])}
  EOT
}

resource "local_file" "manifest" {
  filename = "${path.module}/${local.name_prefix}-manifest.conf"
  content  = <<-EOT
    [manifest]
    services = ${join(",", sort(keys(local.service_map)))}
    retention_days_total = ${sum([for s in local.service_map : s.retention_days])}

    [labels]
    ${join("\n", [for k, v in local.common_labels : "${k} = ${v}"])}
  EOT
}

terraform.tfvars:

environment = "staging"
owner       = "platform"

services = [
  { name = "api", port = 8443, critical = true },
  { name = "worker", port = 9000, critical = false },
]

One of these four locals does not belong. Form an opinion about which before Task 7 measures it.

Configuration changetf-locals-lab/
$ terraform init && terraform apply -auto-approve
local_file.service["api"]: Creation complete after 0s [id=0367be6aa5523fec19ca266b5b48e162b2540e9d]
local_file.manifest: Creation complete after 0s [id=2be278ca220fd0daaa4ba465b344d36ccd6b4ec4]

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Read what the expressions produced:

cat staging-eu-manifest.conf
[manifest]
services = api,worker
retention_days_total = 37

[labels]
environment = staging
managed_by = terraform
owner = platform

37 is 30 + 7, and neither number appears anywhere in the inputs. The critical boolean became a retention policy inside local.service_map, and the manifest summed it without knowing that. That is a derived value with business meaning, and it is precisely what a local is for.

Task 3: Read the locals

There is no terraform locals command and no flag that prints them. The console is the tool:

Read-only / Safetf-locals-lab/
$ echo 'local.name_prefix' | terraform console
"staging-eu"
Read-only / Safetf-locals-lab/
$ echo 'local.service_map' | terraform console
{
  "api" = {
    "labels" = {
      "critical" = "true"
      "environment" = "staging"
      "managed_by" = "terraform"
      "owner" = "platform"
      "service" = "api"
    }
    "port" = 8443
    "retention_days" = 30
  }
  "worker" = {
    "labels" = {
      "critical" = "false"
      "environment" = "staging"
      "managed_by" = "terraform"
      "owner" = "platform"
      "service" = "worker"
    }
    "port" = 9000
    "retention_days" = 7
  }
}

Run the console interactively — terraform console with no pipe — and work through the intermediate steps yourself: keys(local.service_map), local.service_map["api"].retention_days, merge(local.common_labels, { service = "api" }). Building an expression in the console before pasting it into a resource is the difference between one apply and four.

Task 4: Establish what a local is not

A variable can be set from the command line. Try the same with a local:

Read-only / Safetf-locals-lab/
$ terraform plan -var='name_prefix=prod-us'
Error: Value for undeclared variable

A variable named "name_prefix" was assigned on the command line, but the root
module does not declare a variable of that name. To use this value, add a
"variable" block to the configuration.

The error is exactly right and worth reading closely: Terraform does not say “you cannot override a local”, it says there is no variable by that name. local. and var. are separate namespaces. There is no -local flag, no TF_LOCAL_ environment variable, and no entry in any .tfvars file that will reach one.

That is the whole decision rule. If an operator ever needs to set the value — for one environment, in an emergency, from a pipeline — it is a variable. If nobody outside the configuration should be able to set it, it is a local, and making it a local is the enforcement.

Task 5: Find where locals live

They do not live in state. Look:

Read-only / Safetf-locals-lab/
$ jq 'keys' terraform.tfstate
[
  "check_results",
  "lineage",
  "outputs",
  "resources",
  "serial",
  "terraform_version",
  "version"
]

No locals key, and no local name anywhere in the file. Now look at what is there:

grep -c 'managed_by = terraform' terraform.tfstate
3

The label set that local.common_labels produced is in the state three times, once inside each rendered file’s content attribute. The local is not recorded; everything it computed is.

The plan artefact behaves the same way. Save a plan and search it:

terraform plan -out=tfplan
terraform show -json tfplan | jq -r 'paths(scalars) as $p |
  select((getpath($p)|tostring)|test("name_prefix|service_map")) |
  "\($p|join(".")) = \(getpath($p))"'
configuration.root_module.resources.0.expressions.filename.references.1 = local.name_prefix
configuration.root_module.resources.0.for_each_expression.references.0 = local.service_map

Two hits, and both are names in a dependency list, not values. terraform show -json records every variable’s value under .variables; it records no local’s value anywhere.

Task 6: Measure the blast radius

A local’s whole purpose is that one edit reaches many places. That is the benefit and the risk in the same sentence, so measure it.

Flip worker to critical = true in terraform.tfvars and plan:

Read-only / Safetf-locals-lab/
$ terraform plan -out=tfplan && terraform show -json tfplan | jq -r '.resource_changes[] | "\(.address)  \(.change.actions|join(","))"'
local_file.manifest  delete,create
local_file.service["api"]  no-op
local_file.service["worker"]  delete,create

Two of three resources replaced. The manifest is the interesting one — no input names it, and nothing in the diff mentions it:

Read-only / Safetf-locals-lab/
$ terraform show -no-color tfplan | grep -v 'content_'
  # local_file.manifest must be replaced
-/+ resource "local_file" "manifest" {
      ~ content              = <<-EOT # forces replacement
            [manifest]
            services = api,worker
          - retention_days_total = 37
          + retention_days_total = 60
            
            [labels]
            environment = staging
            managed_by = terraform
            owner = platform
        EOT
      ~ id                   = "2be278ca220fd0daaa4ba465b344d36ccd6b4ec4" -> (known after apply)
        # (3 unchanged attributes hidden)
    }

37 became 60 because a boolean two hops upstream turned a 7 into a 30. That chain is the value of the local — the total is computed, not maintained — and it is also the thing a reviewer has to be able to follow. Revert the flip before continuing.

Now change environment from staging to production and plan again:

Read-only / Safetf-locals-lab/
$ terraform plan -out=tfplan && terraform show -json tfplan | jq -r '.resource_changes[] | "\(.address)  \(.change.actions|join(","))"'
local_file.manifest  delete,create
local_file.service["api"]  delete,create
local_file.service["worker"]  delete,create

Plan: 3 to add, 0 to change, 3 to destroy. Everything, because local.name_prefix is in every filename and the filename forces replacement.

Revert environment to staging before continuing.

Task 7: Audit and inline

The rule from the lesson is that a local referenced exactly once is an indirection tax. Make it measurable. Write audit-locals.sh:

#!/usr/bin/env bash
# Count how many times each local declared in this module is referenced.
set -euo pipefail

names=$(awk '/^locals[[:space:]]*\{/{inblock=1; next}
             inblock && /^\}/{inblock=0}
             inblock && /^  [a-z_]+[[:space:]]*=/{gsub(/[^a-z_]/,"",$1); print $1}' ./*.tf)

for name in $names; do
  uses=$(grep -o "local\.${name}\b" ./*.tf | wc -l)
  printf '%-16s %s reference(s)\n' "$name" "$uses"
done

It reads top-level keys out of every locals block and counts local.<name> across the module. It is deliberately crude — two-space indentation, no nested-block awareness — because a heuristic you can read in ten seconds gets run, and a robust HCL parser you have to install does not.

Read-only / Safetf-locals-lab/
$ chmod +x audit-locals.sh && ./audit-locals.sh
env              1 reference(s)
name_prefix      2 reference(s)
common_labels    2 reference(s)
service_map      3 reference(s)

local.env is var.environment under a shorter name, used once. It adds a hop for the reader and buys nothing. Delete it and use the variable directly:

locals {
  name_prefix = "${var.environment}-eu"

  common_labels = {
    environment = var.environment
    owner       = var.owner
    managed_by  = "terraform"
  }
  ...
}

Re-run the audit — three locals, all with more than one reference — and then prove the refactor was free:

Read-only / Safetf-locals-lab/
$ terraform plan
No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

An empty plan is what makes a refactor a refactor. If removing a local changes the plan, it was not a rename — it was doing something, and you have just found out what.

Task 8: Where a secret ends up

Locals have no sensitive argument. Prove that first, because the way they fail to have one is a trap. In the console:

echo 'local.sensitive' | terraform console

Adding sensitive = true inside a locals block does not mark anything. It declares a local called sensitive whose value is true, and the value you meant to protect is untouched. There is no error and no warning.

What does happen is inheritance. In a scratch directory, build a local from a variable that is marked sensitive:

variable "db_password" {
  type      = string
  sensitive = true
  default   = "hunter2-not-a-real-password"
}

locals {
  connection_string = "postgres://app:${var.db_password}@db.example.com:5432/app"
}

resource "local_file" "conf" {
  filename = "${path.module}/conn.conf"
  content  = local.connection_string
}

The plan redacts it:

  # local_file.conf will be created
  + resource "local_file" "conf" {
      + content              = (sensitive value)

The state does not:

Read-only / Safescratch directory
$ jq -r '.resources[].instances[].attributes.content' terraform.tfstate
postgres://app:hunter2-not-a-real-password@db.example.com:5432/app

The state records that the attribute is sensitive — there is a sensitive_attributes list beside it naming content — and stores the value in cleartext regardless. The rendered file on disk is cleartext too.

Validation

  1. cat staging-eu-manifest.conf shows retention_days_total = 37 and three labels in alphabetical order.
  2. echo 'local.service_map' | terraform console returns a two-key map with retention_days of 30 for api and 7 for worker.
  3. terraform plan -var='name_prefix=prod-us' fails with Value for undeclared variable.
  4. jq 'keys' terraform.tfstate contains no locals key, and grep -c 'managed_by = terraform' terraform.tfstate returns 3 — one per rendered file.
  5. ./audit-locals.sh lists three locals, none with fewer than two references.
  6. terraform plan after the Task 7 inlining reports No changes. Your infrastructure matches the configuration.
  7. You can say, in one sentence each, why local.env was removed and why local.service_map was kept at a similar reference count.

Expected Outcome

tf-locals-lab/
├── .terraform.lock.hcl
├── audit-locals.sh              three locals, all multi-reference
├── main.tf                      local.env removed in Task 7
├── terraform.tfvars             environment = "staging"
├── staging-eu-api.conf          retention_days = 30
├── staging-eu-manifest.conf     retention_days_total = 37
├── staging-eu-worker.conf       retention_days = 7
├── terraform.tfstate
└── tfplan

You have a locals block where each entry does one of the three jobs locals are for, an audit that says so, and the empty plan that proves the last refactor cost nothing.

Troubleshooting

Error: Cycle: local.a (expand), local.b (expand). Two locals reference each other. Terraform resolves locals by dependency, not by the order they are written, so a cycle is the only ordering problem that exists — declaring a local after the local that uses it is perfectly legal and works.

Error: Duplicate local value definition. The same name appears in two locals blocks. Multiple locals blocks in a module are allowed and often good practice — one for naming, one for tags, one for maps — but the names across all of them share a single namespace:

A local value named "name_prefix" was already defined at dup.tf:2,3-26. Local
value names must be unique within a module.

Error: Duplicate object key from a for expression. Two input items produced the same key. With var.services that means two services share a name. Terraform names the offending key in the error. Fix the input, or add ... after the value expression to group duplicates into a list — but for a for_each map, grouping is almost never what you want, because the duplicate is a data-quality bug you are about to encode into resource identity.

A local and a variable with the same name. This is not an error and nothing is shadowed. var.region and local.region are separate namespaces holding separate values; a configuration with both will happily report "eu-west-1" for one and "us-east-1" for the other. It is legal, it is confusing, and the reviewer who assumes they are the same value is the failure mode — not the parser.

terraform console cannot resolve a local that references a resource. The console needs state to resolve resource attributes. In a directory that has never been applied, an expression depending on a resource returns an unknown value rather than an error. Apply first, or test the expression against literals.

The audit script prints nothing. It matches top-level keys by two-space indentation. If your locals block is indented differently, or the block opens as locals{ with no space, adjust the awk patterns — the script is 12 lines precisely so it is cheaper to edit than to work around.

Cleanup

Everything this lab created lives under one directory. No system configuration was changed and no privilege was escalated.

Step 1. Destroy the managed resources so state and disk agree:

Destructivetf-locals-lab/
$ terraform destroy -auto-approve
local_file.service["api"]: Destroying... [id=0367be6aa5523fec19ca266b5b48e162b2540e9d]
local_file.service["api"]: Destruction complete after 0s
local_file.manifest: Destruction complete after 0s
local_file.service["worker"]: Destruction complete after 0s

Destroy complete! Resources: 3 destroyed.

Step 2. Keep the audit script — it is the deliverable, and it works on any module:

mkdir -p "$HOME/terraform-lab-deliverables"
cp "$HOME/tf-locals-lab/audit-locals.sh" \
   "$HOME/terraform-lab-deliverables/audit-locals.sh"

Step 3. Remove the working directory.

ls -la "$HOME/tf-locals-lab"
rm -rf "$HOME/tf-locals-lab"

What You Learned

  • You built the three things locals are for: a for_each map derived from a list of objects, a naming convention, and a shared label set that two resources merge from. Each of them turned inputs into a value with meaning — critical = true became retention_days = 30 became a manifest total of 37, and no intermediate number was ever typed.
  • A local cannot be set from outside. -var='name_prefix=...' failed with Value for undeclared variable, because local. and var. are separate namespaces. That is the decision rule: if an operator might need to set it, it is a variable.
  • Locals are absent from state and from the plan JSON, while every value they compute is present in both. Policy engines read the plan JSON, so a derived value that has to be enforceable must reach a resource attribute.
  • You measured the blast radius: one boolean replaced two of three resources and changed a total in a file nobody edited; one string in the environment name replaced all three, because the naming local is part of every resource’s identity.
  • The audit turns a style opinion into a number. local.env had one reference and was a rename, so it went. local.service_map has a similar count and stays, because a twelve-line for expression inlined into for_each is worse. The count starts the argument; it does not finish it.
  • sensitive is not an argument on a locals block. Writing it there creates a local called sensitive. The mark does propagate from a sensitive variable through a local into a resource attribute and keeps the value out of plan output — and the state file holds it in cleartext regardless.

Deliverables

  • · A working configuration whose three locals each carry more than one reference
  • · audit-locals.sh, which counts references to every local declared in a module
  • · A before-and-after audit with the empty plan that proves the inlining changed nothing

Verification status

Last reviewed
2026-08-19
Executed end to end
2026-08-19