Choosing HCL2 Over Legacy JSON for Packer Templates: A Decision Guide
A decision guide for standardizing on Packer HCL2 templates: constraints, a comparison with legacy JSON and script wrappers, a concrete validated template, and CI checks before promotion.
05 Feb 2026, 06:11 UTC

If you maintain Packer templates, the format decision is already made for you in one direction: HashiCorp deprecated the legacy JSON template format and stopped adding features to it. HCL2 (HashiCorp Configuration Language, version 2) is the only template format receiving new builders, plugins, and language features. The real question is not whether to standardize on HCL2, but how to migrate existing JSON templates safely and structure new ones so they stay reviewable and reproducible in CI.
The decision and its constraints
The decision: adopt HCL2 as the single template format, migrate legacy JSON with packer hcl2_upgrade, and pin both the Packer CLI and plugin versions in CI so builds stay reproducible.
Constraints that shape the decision:
- Existing JSON templates still work on older Packer releases, so migration can be gradual, but new features (new builders, new plugin capabilities) land in HCL2 only.
- HCL2 requires a reasonably recent Packer version. Pin the CLI version in CI rather than relying on whatever a runner image happens to ship.
- Since Packer 1.7, external plugins are declared in a
required_pluginsblock and installed withpacker init. This makes plugin versions explicit, but only if you actually pin them.
Comparing the options
| Option | Support status | Strengths | Risks |
|---|---|---|---|
| Legacy JSON templates | Deprecated, frozen feature set | Familiar to long-time users; no migration work today | Incompatible with newer plugins and builders over time; no typed variables or validation |
| HCL2 templates | Actively supported | Typed variables with validation rules, locals, dynamic blocks, clearer diffs in code review, better error messages | Learning curve; one-time migration effort; version-sensitive plugin behavior |
| Wrapping Packer in external scripts | N/A (tooling pattern) | Can paper over gaps in either format | Tooling drift; bypasses packer validate; harder to onboard new maintainers |
Trade-offs in practice
HCL2's upfront cost is real: your team learns a new (if Terraform-familiar) syntax, and migration is not fully automatic. packer hcl2_upgrade handles common cases well, but complex JSON template functions and comments do not always survive conversion cleanly — review the diff by hand before deleting the original.
What you get in return:
- Variable validation. A
validationblock inside avariableblock fails fast on bad input (e.g., rejecting an unknown region) instead of failing 20 minutes into a build. - Reusable locals. Compute values once (timestamps, name prefixes) and reference them everywhere, instead of repeating string concatenation.
- Reviewable diffs. HCL2's block structure produces smaller, more readable pull-request diffs than nested JSON.
- Explicit plugin versions.
required_pluginspluspacker initmeans a template valid today stays valid next quarter, because the plugin version is part of the code.
Staying on JSON is the higher-risk path: nothing breaks immediately, but each new plugin release increases the chance that a builder your pipeline depends on no longer accepts the legacy format.
A concrete HCL2 structure
The pattern that scales well is: typed variables with validation, a parameterized source block, and a build block chaining provisioners plus a manifest post-processor for machine-readable outputs. Run this with any recent Packer (1.8+ recommended) and the Docker plugin; no cloud credentials needed, which makes it a good first migration target.
packer {
required_plugins {
docker = {
source = "github.com/hashicorp/docker"
version = "~> 1.0"
}
}
}
variable "base_image" {
type = string
default = "ubuntu:22.04"
validation {
condition = can(regex("^ubuntu:", var.base_image))
error_message = "base_image must be an Ubuntu image tag."
}
}
variable "app_version" {
type = string
# No default: forces callers to set it explicitly.
}
locals {
image_tag = "myapp:${var.app_version}"
}
source "docker" "app" {
image = var.base_image
commit = true
}
build {
sources = ["source.docker.app"]
provisioner "shell" {
inline = ["echo Building ${local.image_tag}"]
}
post-processor "docker-tag" {
repository = "myapp"
tags = [var.app_version]
}
post-processor "manifest" {
output = "manifest.json"
}
}Notes on the pieces:
required_pluginspins the Docker plugin to a 1.x release.packer initinstalls exactly that, so a plugin update cannot silently change build behavior.- The
validationblock rejects a non-Ubuntu base image atpacker validatetime, before any container starts. app_versionhas no default, so a CI job that forgets to pass it fails immediately with a clear message.- The
manifestpost-processor writes a JSON file with artifact IDs — downstream jobs can read it to record or test exactly what was built.
Validating before you promote
Run these in CI, from the repo root, with the pinned Packer version. All are read-only except packer init, which only downloads plugins into the working directory:
packer init .
packer fmt -check .
packer validate -var "app_version=1.4.2" .Expected result: each command exits 0. A non-zero exit from validate prints the failing variable or block — fix before building. Then run a real build against the cheapest source:
packer build -var "app_version=1.4.2" -only=docker.app .-only targets a single source when the template defines several. After the build, confirm manifest.json exists and contains the artifact ID you expect — that file is your promotion gate's input.
Limitations and cautions
packer hcl2_upgradeoutput needs manual review for complex JSON functions and comments; do not trust it blind.- Builder behavior is version-sensitive. A template valid for one plugin release can fail on another — another reason to lock
required_plugins. - Post-processors like
docker-pushorvagranthave side effects. Keep them out of validation-only CI jobs, or gate them behind a separate publish stage. - Provisioners that install unpinned packages make builds non-deterministic; pin software versions where possible.
The practical check that migration worked: build the same image from the old JSON template and the new HCL2 template, then diff the resulting artifacts (or their manifest entries). Equivalent output plus a green packer validate in CI is the signal to delete the JSON.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.