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.iofor the firstterraform 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.
$ terraform init && terraform apply -auto-approvelocal_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:
$ echo 'local.name_prefix' | terraform console"staging-eu"$ 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:
$ 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:
$ 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:
$ 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,createTwo of three resources replaced. The manifest is the interesting one — no input names it, and nothing in the diff mentions it:
$ 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:
$ 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,createPlan: 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.
$ chmod +x audit-locals.sh && ./audit-locals.shenv 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:
$ terraform planNo 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:
$ jq -r '.resources[].instances[].attributes.content' terraform.tfstatepostgres://app:hunter2-not-a-real-password@db.example.com:5432/appThe 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
cat staging-eu-manifest.confshowsretention_days_total = 37and three labels in alphabetical order.echo 'local.service_map' | terraform consolereturns a two-key map withretention_daysof 30 forapiand 7 forworker.terraform plan -var='name_prefix=prod-us'fails withValue for undeclared variable.jq 'keys' terraform.tfstatecontains nolocalskey, andgrep -c 'managed_by = terraform' terraform.tfstatereturns 3 — one per rendered file../audit-locals.shlists three locals, none with fewer than two references.terraform planafter the Task 7 inlining reportsNo changes. Your infrastructure matches the configuration.- You can say, in one sentence each, why
local.envwas removed and whylocal.service_mapwas 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:
$ terraform destroy -auto-approvelocal_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_eachmap 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 = truebecameretention_days = 30became a manifest total of 37, and no intermediate number was ever typed. - A local cannot be set from outside.
-var='name_prefix=...'failed withValue for undeclared variable, becauselocal.andvar.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.envhad one reference and was a rename, so it went.local.service_maphas a similar count and stays, because a twelve-lineforexpression inlined intofor_eachis worse. The count starts the argument; it does not finish it. sensitiveis not an argument on alocalsblock. Writing it there creates a local calledsensitive. 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.