Packer’s locals and data blocks let you avoid repeating yourself, but their power comes from understanding how Packer resolves them at different stages of the build.
Let’s see Packer in action. Imagine you’re building a Debian AMI and want to use a common set of packages across multiple builders.
variable "region" {
type = string
default = "us-east-1"
}
locals {
common_packages = [
"htop",
"vim",
"git",
"curl",
"wget"
]
}
data "amazon-ami" "debian" {
most_recent = true
owners = ["123456789012"] # Replace with your AWS Account ID
filter {
name = "name"
values = ["debian-11-amd64-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
source "amazon-ebs" "debian_builder" {
ami_name = "debian-custom-${timestamp()}"
instance_type = "t3.micro"
region = var.region
source_ami = data.amazon-ami.debian.id
ssh_username = "admin"
tags = {
Name = "DebianCustom"
}
}
build {
sources = ["source.amazon-ebs.debian_builder"]
provisioner "shell" {
inline = [
"echo 'Installing common packages...'",
"sudo apt-get update -y",
"sudo apt-get install -y ${join(",", local.common_packages)}"
]
}
}
When Packer runs packer init ., it first evaluates data sources. This is why the source_ami in the amazon-ebs builder can directly reference data.amazon-ami.debian.id. Packer fetches the latest Debian AMI ID and makes it available for the builder configuration.
The locals block, however, is evaluated after data sources but before builders are fully configured. This means local.common_packages can be used within the provisioner block. The join(",", local.common_packages) expression takes your list of packages and turns it into a comma-separated string like "htop,vim,git,curl,wget", which is exactly what apt-get install expects.
The magic here is that locals are evaluated once per template, and their values are fixed for the entire build run. data sources are also evaluated once, but their output can be consumed by both other data sources and builder configurations. You can chain them, too. For instance, a data "aws-vpc" could be used to dynamically find a VPC ID, and then another data "aws-subnet" could use that VPC ID to find a subnet.
You might think that locals are just variables, but they’re more powerful because they can reference the output of data sources. A local block can look like this:
locals {
# This local will resolve to the ID of the Debian AMI found by the data source
debian_ami_id = data.amazon-ami.debian.id
}
Then, in your builder:
source "amazon-ebs" "debian_builder" {
# ... other config
source_ami = local.debian_ami_id
# ... other config
}
This is particularly useful when you have complex configurations or want to derive values. For example, you could have a local that calculates a deployment tag based on the current date and a base name.
The key takeaway is the evaluation order: data sources run first, then locals (which can reference data source outputs), and finally builders are configured using these resolved values. This allows for dynamic discovery of infrastructure details and structured reuse of configuration parameters.
Packer’s locals and data blocks offer a robust way to manage dynamic values and shared configurations, but it’s crucial to understand that data sources are resolved before locals, and locals are resolved before builder configurations are finalized. This deterministic order is what makes them so powerful for creating complex, repeatable infrastructure definitions.
The next step is understanding how packervars interact with locals and data sources during packer init and packer build.