One Packer Template, Many Images: Variable-Driven Builds with HCL2
Stop duplicating Packer templates per environment. Typed HCL2 variables, templatefile() for external scripts, and post‑processors turn one template into a small image factory.
27 Dec 2025, 02:32 UTC

If you maintain machine images for more than one environment, you've probably hit the copy‑paste wall: a JSON Packer template for staging, a near‑identical one for production, and a third someone forked six months ago that now installs a different package set. The fix isn’t discipline — it’s making the template itself parameterizable so the differences live in variable files, not in duplicated code.
Since Packer 1.6, the HCL2 language (the same configuration language Terraform uses) gives you typed variables, locals, and built‑in functions that turn a single template into a small image factory. This post walks through the engineering decisions that make that work in practice. Examples assume Packer 1.8 or later; HCL2 syntax is not compatible with Packer 1.5‑era JSON templates.
Variables are the contract, not an afterthought
The most common mistake is treating variables as stringly‑typed placeholders. HCL2 lets you declare type constraints and defaults, which turns your template’s inputs into a checked contract:
variable "region" {
type = string
default = "us-east-1"
}
variable "app_version" {
type = string
# no default: the build fails fast if the caller forgets it
}
variable "instance_size" {
type = string
default = "small"
validation {
condition = contains(["small", "large"], var.instance_size)
error_message = "instance_size must be small or large."
}
}
Two decisions matter here. First, omitting a default for app_version makes it required — Packer will prompt or fail rather than silently baking an image with a meaningless version string. Second, a validation block catches typos at packer validate time instead of 20 minutes into a build. Run packer inspect . to confirm the template parses and lists the variables and their defaults before you ever launch a build.
Keep provisioning logic in files, not inline strings
Inline shell in a template rots quickly: no syntax highlighting, no shellcheck, no way to test the script independently. HCL2’s file() and templatefile() functions let you keep scripts as real files. file() reads a file verbatim; templatefile() renders it first, substituting variables:
source "null" "example" {
communicator = "none"
}
build {
sources = ["source.null.example"]
provisioner "shell-local" {
inline = [
"echo '${templatefile("${path.root}/scripts/setup.sh.tmpl", {
app_version = var.app_version
})}' > rendered_setup.sh"
]
}
}
Where scripts/setup.sh.tmpl might contain:
#!/usr/bin/env bash
set -euo pipefail
echo "Installing app version ${app_version}"
The payoff: one template, and per‑environment differences expressed as packer build -var-file=prod.pkrvars.hcl . versus -var-file=staging.pkrvars.hcl. The null builder above is a safe way to test rendering locally — it requires no cloud credentials and changes no remote state. For a real AMI build you’d swap in amazon-ebs, but the variable and templating mechanics are identical.
Locals for computed values, post‑processors for delivery
When several fields derive from the same input, compute them once in a locals block rather than repeating string interpolation:
locals {
image_name = "myapp-${var.app_version}-${formatdate("YYYYMMDD", timestamp())}"
}
Then reference local.image_name in the source and in a post‑processor. Post‑processors run after the build inside the same Packer workflow — for example, the vagrant post‑processor packages the artifact as a Vagrant box, or manifest records the artifact ID as JSON that downstream CI steps can read. That last one is underrated: emitting a manifest file means your pipeline never has to scrape Packer’s console output for an AMI ID.
The trade‑offs worth knowing
Nothing here is free. HCL2 templates are not backward compatible with Packer 1.5 and earlier — legacy JSON templates must be migrated, either by hand or with packer hcl2_upgrade, and the generated output usually needs cleanup. Also, templatefile() reads the entire file into memory before rendering, so it’s a poor fit for embedding very large files; use a file provisioner to copy those instead. Finally, heavy parameterization has its own failure mode: a template with thirty variables is its own kind of duplication. A good rule is to parameterize only what genuinely differs between builds, and pin everything else.
Try it this week
Pick one duplicated template pair and merge them: extract the differing values into typed variables, move inline scripts into files rendered with templatefile(), and verify with packer inspect . and a null‑builder run before touching real infrastructure. You’ll know it worked when adding a third environment means writing a five‑line .pkrvars.hcl file and nothing else.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.