Packer’s HCL2 syntax is actually a step backward in terms of its core functionality, despite being more modern.
Let’s see this in action. Imagine you want to build a simple Ubuntu AMI using Packer.
packer {
required_plugins {
amazon = {
version = ">= 1.0.0"
source = "github.com/hashicorp/amazon"
}
}
}
source "amazon-ebs" "ubuntu" {
region = "us-east-1"
instance_type = "t2.micro"
ssh_username = "ubuntu"
ami_name = "ubuntu-{{timestamp}}"
source_ami_filter {
filters = {
name = "ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"
root-device-type = "ebs"
virtualization-type = "hvm"
}
most_recent = true
owners = ["099720109477"] # Canonical's owner ID
}
}
build {
name = "ubuntu-build"
sources = [
"source.amazon-ebs.ubuntu"
]
provisioner "shell" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y nginx",
"sudo systemctl enable nginx"
]
}
}
This HCL2 template defines a Packer build. The packer block specifies required plugins, here the amazon plugin. The source block configures how Packer will launch an EC2 instance to build the AMI, detailing the region, instance type, SSH user, and importantly, how to find the base Ubuntu AMI using source_ami_filter. The build block orchestrates the process, linking the source to the provisioner which executes shell commands to install and enable Nginx.
Packer HCL2, by moving away from JSON, allows for more expressive and readable configurations. It introduces concepts like first-class functions, loops, and conditional logic directly within the template itself, which were previously only achievable through complex external templating or by abandoning Packer for more sophisticated IaC tools. This means you can now define more dynamic build processes without resorting to external scripting. For instance, you can iterate over a list of regions to build AMIs in multiple places simultaneously, or conditionally include certain provisioners based on environment variables.
The core problem HCL2 solves is making Packer configurations more maintainable and powerful for complex scenarios. Instead of a static JSON file that needs external processing for any dynamism, HCL2 allows you to define that dynamism directly. This is particularly useful when you have parameterized builds, such as creating AMIs for different operating systems, different cloud providers, or different application versions, all within a single, coherent template structure.
Here’s a deeper dive into how it works internally. HCL2 parses your configuration into an Abstract Syntax Tree (AST). This AST is then evaluated, resolving variables, executing functions, and constructing the final execution plan. This evaluation process is what makes the dynamic features possible. When you use a function like timestamp() or lookup(), HCL2 evaluates it during this phase, injecting the result into the configuration before Packer starts interacting with the cloud provider.
The most surprising thing about Packer’s HCL2 is that it’s not a full-blown programming language. Despite its ability to do loops, conditionals, and use functions, it’s intentionally scoped. It’s a configuration language designed for infrastructure, not general-purpose programming. This means you won’t be writing complex algorithms or data structures within your Packer templates. Its power lies in its declarative nature, making infrastructure definitions clear and manageable, while providing just enough expressiveness to handle common dynamic needs.
The next hurdle you’ll likely encounter is managing state and secrets within your HCL2 Packer builds, especially as they grow in complexity.