Packer
Packer is the tool that actually does the baking behind immutable infrastructure & golden images: a single static binary from HashiCorp that reads one declarative HCL2 template, launches disposable build compute through a plugin for whichever platform you're targeting, runs your provisioning steps against it exactly once, and snapshots the result into a versioned artifact — an AMI, a Google Compute Engine image, an Azure managed image, a Docker image, a Vagrant box — before tearing the build compute down. The point isn't any one of those artifacts. It's that the same template, the same shell script, the same Ansible playbook, can produce all of them in one run, so "golden" actually means something: three artifacts built from one recipe can't silently drift from each other the way three hand-written per-cloud bake scripts always eventually do.
Imagine you've perfected a birthday cake recipe and now you need that exact cake baked in three totally different kitchens — a wood-fired oven, a food truck's tiny convection oven, and your own oven at home — and every one has to come out identical, crumb for crumb, today and again next month. You don't rewrite the recipe three times and hope nobody fat-fingers a temperature in one copy. You hand the same recipe card to all three ovens, let each one bake, and then it's the finished cakes that matter — not the ovens, which get used once and put away. Packer is the recipe card. Amazon, Google, Azure, and Docker are the ovens. Packer's whole job is making sure the cake is identical no matter which oven baked it.
What Packer is, and the problem it solves
☺ Like you're 10: Without Packer, "golden image" is really three separate hand-written scripts pretending to be one recipe — and the day someone only updates one of them, the pretending stops.
HashiCorp released Packer in 2013, a year before Terraform, and it shares Terraform's core shape: a single binary, no server, no daemon watching anything between runs. Before Packer, teams that needed the same application baked into an AWS AMI and a Google Compute Engine image and a local Vagrant box for developer parity generally wrote three separate scripts — one per cloud CLI, each hand-rolling "launch an instance, wait for SSH, run some commands, snapshot it, clean up." Those three scripts start out identical and never stay that way. Someone patches the AWS script during an incident and forgets the GCP one exists. A provisioning step gets reordered in one copy but not the others. Within a quarter, "golden image" describes three different images that happen to share a name.
Packer's fix is to make the recipe the one artifact that's versioned and reviewed, and to make each cloud's bake mechanics a plugin underneath it rather than a bespoke script beside it. A source block says which builder plugin to use and what to launch it from; a build block lists provisioners to run against whatever that source launches. Change the provisioners once, and every platform listed in that same template picks up the change on the next packer build — there is structurally no way for the AWS artifact and the GCP artifact to run different setup steps unless you explicitly scope a provisioner to only one of them.
Packer's scope stops exactly at "produce the artifact." It does not launch that artifact into production — that's Terraform, an Auto Scaling Group, or kubectl's job — and it does not keep configuring a running fleet after boot, which is configuration management's job, covered in more depth (including exactly how the two disciplines divide the work) in immutable infrastructure & golden images. Packer is deliberately a build-time tool with no runtime opinion at all.
Architecture: plugins, sources, provisioners, post-processors
☺ Like you're 10: Packer core doesn't know how to talk to AWS or run a shell script — it hands that off to small plugins, one job each, and just conducts the order they run in.
Packer core is deliberately thin. It parses your HCL2 template, resolves variables, and orchestrates a build — everything that actually does something is a plugin, and since Packer 1.7 (2021) those plugins live outside the core binary as separately versioned, separately released executables. Four plugin kinds cover essentially everything you'll touch:
| Plugin kind | Job | Examples |
|---|---|---|
Builders (declared as source blocks) | Talk to one platform's API, launch temporary build compute from a base image, and turn the finished state into an artifact | amazon-ebs, amazon-chroot, googlecompute, azure-arm, docker, qemu, vsphere-iso, virtualbox-iso, vagrant |
| Provisioners | Run inside or against that temporary build compute to actually configure it | shell, shell-local, ansible, file, powershell, windows-restart, chef-client, puppet-masterless, breakpoint |
| Post-processors | Operate on the finished artifact after the builder is done with it | manifest, docker-tag, docker-push, compress, checksum, amazon-import, hcp-packer-registry |
| Data sources | Read-only lookups a template can reference before or during a build | amazon-ami (find a base AMI by filter), http, hcp-packer-image (pull HCP Packer's latest published fingerprint) |
A template's packer block declares which plugins it needs and at what version, the same shape as Terraform's required_providers:
packer {
required_version = ">= 1.10.0"
required_plugins {
amazon = {
version = ">= 1.3.1"
source = "github.com/hashicorp/amazon"
}
docker = {
version = ">= 1.0.9"
source = "github.com/hashicorp/docker"
}
ansible = {
version = ">= 1.1.1"
source = "github.com/hashicorp/ansible"
}
}
}packer init template.pkr.hcl reads that block and downloads matching plugin binaries into an OS-specific config directory (~/.config/packer/plugins on Linux and macOS, %APPDATA%\packer\plugins on Windows, overridable with PACKER_PLUGIN_PATH). Run it once per machine — or once per CI job, if the runner is ephemeral — before validate or build; without it, older Packer versions relied on plugins being manually installed on PATH, which is exactly the kind of "works on my laptop" drift packer init exists to remove.
The build lifecycle, start to finish
☺ Like you're 10: Every builder does the same four things — turn something on, do the setup, take a photo of the result, turn it back off — no matter which cloud it's talking to.
Whatever the platform, a Packer build follows the same shape, and understanding it is most of the debugging skill you'll need later. For a cloud builder like amazon-ebs: Packer launches a temporary instance from whatever base image the source block names, waits for a communicator (SSH, or WinRM for Windows builds) to become reachable, runs every declared provisioner against that instance in order, runs any post-processors against the result, has the platform snapshot the instance's disk into a new artifact (an AMI, in this case), and terminates the temporary instance. Nothing about that instance persists into the final artifact except its disk contents — no SSH keys Packer generated for the session, no temporary security group, no trace it was ever billed for.
Docker and other local builders (qemu, virtualbox-iso) do the identical dance without leaving your machine — docker starts a container instead of an instance, runs the provisioners against it with docker exec, and commits the container's filesystem to a new image layer instead of asking a cloud API for a snapshot. There's also a faster variant worth knowing for AWS specifically: amazon-chroot skips launching a full instance at all. It mounts an existing EBS volume directly onto the Packer host instance, chroots into it, provisions in place, and snapshots that volume — no boot, no network round trip to a new instance, dramatically faster for teams baking dozens of AMI variants a day. It's the mechanism large, homogeneous fleets (Netflix's early Aminator tooling, mentioned in immutable infrastructure & golden images, worked the same way) reach for once amazon-ebs's per-build boot time becomes the pipeline's bottleneck.
One template, many platforms: the multi-source build
☺ Like you're 10: You don't write the recipe three times — you write it once and list which ovens should use it.
This is the pattern the content brief for this page is really about, and it's the part that separates Packer from "a script that happens to call several cloud CLIs." A single build block can list several source references, and by default Packer runs all of them in parallel, each getting the identical sequence of provisioners:
variable "app_version" {
type = string
default = "1.4.3"
}
locals {
# RFC 3339 timestamps contain colons, which AWS AMI names reject outright —
# strip everything that isn't alphanumeric before using it in a name.
build_time = regex_replace(timestamp(), "[- TZ:]", "")
}
source "amazon-ebs" "web" {
ami_name = "web-${var.app_version}-${local.build_time}"
instance_type = "t3.medium"
region = "us-east-1"
source_ami_filter {
filters = { name = "al2023-ami-*-x86_64", virtualization-type = "hvm" }
owners = ["amazon"]
most_recent = true
}
ssh_username = "ec2-user"
}
source "googlecompute" "web" {
project_id = "acme-prod"
source_image_family = "debian-12"
zone = "us-central1-a"
image_name = "web-${var.app_version}-${local.build_time}"
ssh_username = "packer"
}
source "docker" "web" {
image = "ubuntu:22.04"
commit = true
}
build {
name = "web"
sources = [
"source.amazon-ebs.web",
"source.googlecompute.web",
"source.docker.web",
]
# runs against ALL three sources — the shared recipe
provisioner "shell" {
inline = ["sudo apt-get update -y || sudo dnf -y update"]
}
provisioner "ansible" {
playbook_file = "../playbooks/webserver.yml"
}
# runs ONLY against the docker source — platform-specific cleanup
provisioner "shell" {
only = ["docker.web"]
inline = ["apt-get clean", "rm -rf /var/lib/apt/lists/*"]
}
post-processor "manifest" {
output = "manifest.json"
}
}Two details there matter more than they look like they should. First, only and except on a provisioner take the two-part type.name form ("docker.web"), not the three-part source.type.name form the build block's own sources list uses — a genuinely common typo. Second, the shell and Ansible provisioners with no only/except run against every source in the list, which is the entire point: the security-patching command and the application-installing playbook exist exactly once in this file, and every artifact it produces ran through them identically. Add a fourth platform later — a vagrant source, say, to hand developers a local box that matches production bit for bit — and it inherits every existing provisioner for free by being added to the sources list, nothing else has to change.
The CLI mirrors this at build time without touching the file: packer build -only='amazon-ebs.web' template.pkr.hcl builds just that one source (useful in CI when you only need to refresh one platform's artifact), while packer build -except='googlecompute.web' template.pkr.hcl builds everything except it. Note the CLI flag's target name is again the two-part form, matching a provisioner's only/except, not the build block's three-part sources list.
The provisioners and post-processors you'll actually reach for
☺ Like you're 10: Provisioners do the actual setup work inside the temporary machine; post-processors tidy up and label the finished photo afterward.
| Block | What it does | Reach for it when |
|---|---|---|
provisioner "shell" | Runs inline commands or a script over the communicator (SSH/WinRM) | OS package installs, quick one-off setup steps |
provisioner "ansible" | Runs ansible-playbook from the Packer host, targeting the temp instance as its only inventory entry | You already maintain playbooks for configuration management and want to reuse them at bake time |
provisioner "file" | Uploads a local file or directory into the temp instance before other provisioners run | Dropping in a config file, TLS cert, or license that a later provisioner will install |
provisioner "breakpoint" | Pauses the build and prints SSH connection details instead of continuing | Debugging a provisioner failure — you can log into the exact live temp instance instead of guessing from a torn-down build's log |
post-processor "manifest" | Writes the resulting artifact ID(s) and build timestamp to a JSON file | Feeding the built AMI ID into a downstream Terraform run or CI step, the way immutable infrastructure & golden images uses it |
post-processor "docker-tag" / "docker-push" | Tags a locally built Docker image and pushes it to a registry | The docker source needs its result to end up somewhere other than the local Docker daemon |
post-processor "checksum" | Writes a checksum file for the finished artifact | Downstream tooling needs to verify the artifact wasn't altered in transit |
post-processor "hcp-packer-registry" | Publishes the build's metadata and content fingerprint to HCP Packer | You want Terraform to look up "the latest validated image" instead of hardcoding an AMI ID — see below |
A packer build that finishes without error only proves the provisioners ran — not that the result is correct or safe. Test the finished artifact before anything launches from it in production: tools like Chef InSpec or Goss can assert "nginx is installed, listening on 443, the deploy user exists" against the built image directly, the same discipline Testing in the Pipeline applies to application code. Run a vulnerability scanner like Trivy or Grype against the finished image as a required CI gate, the same commit-gate pattern Shift-Left Security for DevOps uses for application dependencies — see also Supply-Chain Security & SBOM for generating a bill of materials for the image itself. Skip both and a golden image is just a mutable server's bugs, baked in and copied a thousand times.
Day-to-day commands
☺ Like you're 10: Get the plugins, check the file, see what it would do, build it, and a REPL for when you're not sure what an expression evaluates to.
$ packer init template.pkr.hcl # download/verify every plugin in required_plugins
$ packer fmt -recursive . # canonical HCL formatting, in place
$ packer validate -var-file=prod.pkrvars.hcl template.pkr.hcl # syntax + config checks, no build compute touched
$ packer build -var-file=prod.pkrvars.hcl template.pkr.hcl # build every source, in parallel
$ packer build -only='amazon-ebs.web' template.pkr.hcl # build just one source
$ packer build -except='googlecompute.web' template.pkr.hcl # build everything except one
$ packer build -var app_version=1.5.0 template.pkr.hcl # single ad hoc override
$ packer console # a REPL for trying HCL expressions, like terraform console
$ packer inspect template.pkr.hcl # list every variable, source, and build the file declares
$ PACKER_LOG=1 packer build template.pkr.hcl # verbose debug logging when a build fails mysteriouslyVariables follow the same file precedence as Terraform: a -var flag beats a -var-file, and Packer also auto-reads any environment variable named PKR_VAR_<name> as a default for a variable of that name — handy for injecting a CI-only value (an internal AMI account ID, say) without writing it into a committed .pkrvars.hcl file at all.
Take the docker source from immutable infrastructure & golden images and add a second source "docker" "alpine" block pointed at a different base image tag, both listed in the same build block's sources. Run packer build . with no flags and watch the log — each source's output lines are prefixed with its own name, running interleaved but genuinely in parallel. Now run packer build -only='docker.alpine' . and watch only one of them fire. That's the same mechanism a CI matrix uses to rebuild a single platform's artifact without waiting on the other four.
Closing the loop: HCP Packer
☺ Like you're 10: Instead of pasting an AMI ID into ten different Terraform files, one place remembers "the current good one" and everything else just asks it.
HCP Packer is HashiCorp's hosted registry for what Packer builds — a free tier exists alongside the paid one, similar in shape to how Terraform has HCP Terraform sitting optionally on top of the open-source CLI. Add the hcp-packer-registry post-processor to a build, and every successful packer build publishes a record: which artifact IDs it produced, a content fingerprint, and which channel (a named pointer like production or staging) it should be assigned to.
The problem HCP Packer solves is the one every team eventually hits without it: an AMI ID hardcoded into five different Terraform configs, none of which update automatically when a new golden image ships, so half the fleet ends up bootstrapping from a build that's months old by accident. With the registry in place, Terraform reads the current image with a hcp-packer-image data source pointed at a channel name instead of an ID — data "hcp-packer-image" "web" { bucket_name = "web" channel = "production" } — so "promote the new image" becomes moving the channel pointer once, and every consuming Terraform config picks it up on its next plan, the same "one version number, many consumers" idea Helm's chart versioning and Terraform's own module versioning both use.
Gotchas and failure modes
☺ Like you're 10: Most Packer surprises come from the fact that the machine doing the work is meant to disappear — so anything that stops it from disappearing cleanly becomes expensive or invisible.
Orphaned build resources after a killed run
A provisioner that fails, or a Packer process that's interrupted (Ctrl-C, a CI runner OOM-killed mid-build), doesn't always get the chance to run its own cleanup. By default Packer terminates the temporary instance on a provisioner error, but a genuinely killed process can leave a running instance, a temporary security group, and a generated key pair behind — quietly billing until someone notices. Tag everything Packer launches (most builders support a run_tags or equivalent block) and sweep for orphaned resources on a schedule; don't rely on the happy-path cleanup alone. For active debugging rather than accidental orphaning, use -on-error=ask or the breakpoint provisioner deliberately — both leave the instance running on purpose so you can inspect it, which is a very different thing from an unattended CI job losing track of one.
No lock file — plugin drift is a real gap, not a theoretical one
Terraform's .terraform.lock.hcl pins exact provider versions and cryptographic hashes so two machines resolve byte-identical providers. Packer has no equivalent checked-in lock file: required_plugins pins a version constraint, and packer init honors it, but there's nothing recording the exact hash it resolved to the way Terraform does. Two machines running packer init weeks apart against a loose constraint like version = ">= 1.3.1" can genuinely land on different plugin versions with different default behavior. Pin tightly (version = "1.3.1", not a floor) for anything that has to be reproducible, and don't assume "it built the same way in CI" the way you reasonably could with Terraform.
The AMI name colon problem
The native HCL2 timestamp() function returns an RFC 3339 string like 2026-08-16T14:32:05Z — and AWS AMI names reject colons outright, along with most punctuation beyond . / - _ ( ) and spaces. An ami_name built directly from timestamp() fails validation with an error that reads like an AWS problem when it's really a formatting one. The idiomatic fix, shown in the multi-source example above, is regex_replace(timestamp(), "[- TZ:]", "") in a locals block — strip the offending characters once, reuse the result everywhere a name needs a build-time stamp.
"One template" isn't automatically "one image" if base images drift
A multi-source template guarantees every artifact ran through the identical provisioners. It says nothing about whether their base images stayed in sync — a source_ami_filter with most_recent = true can resolve to a different underlying AMI on Tuesday than it did on Monday, and there's no equivalent guarantee that the GCP source's debian-12 family and the AWS source's al2023 filter are patched to the same day. "Identical recipe" and "identical starting ingredients" are two separate promises; Packer only makes the first one for you.
Secrets echoed into build logs
A shell provisioner's commands and their output are written to the build log by default, and PACKER_LOG=1 makes this even more verbose. A database password or API token passed as a plain variable and used inside an inline command is trivially visible in CI logs unless you go out of your way to suppress it. The safer pattern, and the one immutable infrastructure & golden images lands on for the same reason, is not to bake standing secrets into the image at all — fetch short-lived credentials at boot time from HashiCorp Vault or the platform's own identity mechanism instead, covered in Secrets & Credential Management. Anything that genuinely must reach the build (an internal package-repo token, say) belongs in a CI secret store injected as an environment variable at build time, referenced via a sensitive-marked variable, never typed directly into the template.
Communicator timeouts that look like provisioner failures
A cloud instance that takes longer than ssh_timeout's default to become reachable fails the build before a single provisioner runs, with an error that's easy to misread as "the shell script is broken" when it's actually "the instance hadn't finished booting yet." Slow-booting base images (a Windows AMI, an instance type your account has throttled) need that timeout (or winrm_timeout) raised explicitly rather than debugged as a provisioning problem.
Packer vs. its neighbors
☺ Like you're 10: Other tools also turn a recipe into a machine — they just trade away either the multi-platform part or the "someone else manages it" part to get there.
| Option | Model | Best when | Costs you |
|---|---|---|---|
| Packer | One HCL2 template, ephemeral build compute per source, a versioned artifact per platform | The same recipe must produce a genuinely portable image across two or more platforms — cloud, on-prem, and container | Ephemeral build compute costs real time and money per run; no checked-in lock file the way Terraform has |
| Cloud-native image pipelines (AWS EC2 Image Builder, Azure VM Image Builder) | Managed, single-cloud pipeline with built-in scheduling, testing, and distribution | You're single-cloud and want the scheduling and patch-testing managed for you | Locked to one cloud — no shared recipe with an on-prem or multi-cloud target |
| Plain Dockerfile (no Packer) | The build described directly in the container-native format | Your only artifact is a container image and no VM or on-prem target exists | No shared recipe with a VM pipeline — the day a VM artifact shows up too, you're maintaining two build systems |
| Config management applied to a live fleet (no baking at all) | Mutable, continuously reconciled hosts, no artifact step | Early-stage systems where rebuild cost still outweighs drift risk | Config drift and snowflake servers — the exact problem golden images exist to remove, covered in immutable infrastructure & golden images |
| Hand-rolled bake scripts (cloud CLI + SSH, no tool) | Whatever a shell script and aws/gcloud/az do | A single platform, a genuine one-off image, no expectation of repeating this | You reinvent launch/wait/provision/snapshot/cleanup and its retry and timeout edge cases yourself, per cloud — and it rots the moment two people maintain two copies |
The practical rule mirrors Terraform's: reach for Packer the moment "one recipe, more than one platform" is a real requirement rather than a hypothetical one — a production AMI and a developer-parity Vagrant box from the same template is the single most common version of this. If you're building for exactly one platform and never expect a second, Packer's multi-source advantage doesn't pay for its own setup cost, and the platform's own managed image pipeline is the simpler default.
Exam-wise, HashiCorp doesn't currently offer a dedicated Packer certification the way it does Terraform Associate and Vault Associate — verify that against HashiCorp's own certification page before assuming it's stayed that way. If you're preparing for AWS's own DOP-C02 exam instead, image-baking and AMI lifecycle patterns fall under that exam's configuration-management domain, covered alongside EC2 Image Builder in Configuration Management & IaC, with the relevant AWS CLI surface in the command & service reference.
Benny the Beaver: Green across all three. Same Ansible playbook ran against the AWS builder, the GCP builder, and the Docker builder. One recipe, three artifacts.
Foxy: Couldn't you just write three separate bake scripts instead? One per cloud CLI?
Benny the Beaver: Sure — until someone patches only one of the three during an incident and forgets the other two exist. That's not hypothetical, Foxy, that's every "golden image" I've ever inherited from a team without Packer.
Gizmo the Gremlin: Or — hot take — skip the tool entirely. I'll just launch an instance by hand, SSH in, run the setup, and call create-image myself. Way faster. 🤑
Timmy the Turtle: Fine, until you forget to terminate that instance and it's still billing next month — or until "by hand" means the GCP image never gets the same fix at all.
Recon the Robot: And once it's built, HCP Packer remembers which fingerprint is on the production channel, so my Terraform run asks for "the current one" instead of a hardcoded AMI ID nobody updates.
Practice the replace-not-patch mechanics this artifact feeds into over in Drill — Write a Reusable IaC Module and Capstone Part 2 — Infrastructure as Code. For how the baked artifact actually gets rolled out to a live fleet, see immutable infrastructure & golden images; for the tools it pairs with most often, see Terraform, Ansible, and Docker.
1. What problem does a multi-source Packer template solve that three separate per-cloud bake scripts don't? 2. Name the four plugin kinds Packer uses and what each one does. 3. Walk through, step by step, what happens when you run packer build against a template with an amazon-ebs source. 4. Why does ami_name = "web-${timestamp()}" fail validation on AWS, and what's the idiomatic fix? 5. What does packer init do, and what does it not give you that terraform init plus .terraform.lock.hcl does? 6. What is HCP Packer for, and how does a downstream Terraform config consume what it publishes? 7. Name two ways an interrupted Packer build can leave billed resources running, and how you'd catch or avoid that.
Check your answers
- It guarantees every platform's artifact ran through the exact same provisioning steps, because those steps exist once in the template rather than once per script. Separate hand-written scripts start identical and drift the moment one gets patched without the others.
- Builders (declared as
sourceblocks) launch temporary build compute and produce the artifact; provisioners run inside that compute to configure it; post-processors operate on the finished artifact (tagging, manifests, publishing); data sources perform read-only lookups a template can reference, like finding the latest base AMI by filter. - Packer launches a temporary instance from the base image named in the source's filter, waits for the SSH communicator to become reachable, runs every declared provisioner against it in order, runs any post-processors against the result, has AWS snapshot the instance's disk into a new AMI, and terminates the temporary instance.
timestamp()returns an RFC 3339 string containing colons, and AWS AMI names reject colons along with most punctuation. The fix isregex_replace(timestamp(), "[- TZ:]", "")in alocalsblock to strip the offending characters before using the value in a name.packer initdownloads and installs the plugin versions matching a template'srequired_pluginsconstraints. Unlike Terraform, Packer has no checked-in lock file recording the exact resolved version and hash — two machines runningpacker initagainst a loose version constraint can genuinely resolve different plugin versions.- HCP Packer is HashiCorp's registry for what Packer builds — it records each build's artifact IDs and a content fingerprint under a named channel like
production. A Terraform config reads the current image with ahcp-packer-imagedata source pointed at the channel name instead of a hardcoded AMI ID, so promoting a new image is moving the channel pointer once rather than editing every consuming config. - A provisioner failure or a killed Packer process (Ctrl-C, an OOM-killed CI job) can leave the temporary instance, its security group, and a generated key pair running and billing, since cleanup normally only runs on the happy path or on a clean provisioner error. Tagging everything Packer launches and sweeping for orphaned resources on a schedule catches what unattended cleanup misses.