HashiCorp Packer builds machine and container images from a template: it starts a temporary instance from a base image, runs provisioners (shell scripts, file uploads, configuration management) inside it, and saves the result as a new image. Baking software into an image this way, often called a golden image, means every server or container starts from an identical, tested state instead of being configured by hand after boot. In this tutorial you will install Packer on Ubuntu 24.04 and build a versioned Nginx image with an HCL2 template, using the Docker builder so that everything runs on a single server. The template structure is the same one you use for VM images on QEMU or cloud platforms.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM and 5 GB of free disk space.
  • A non-root user with sudo privileges.
  • Docker Engine installed from Docker's official repository, with your user in the docker group (log out and back in after adding it).

Confirm that Docker works without sudo:

docker run --rm hello-world

The output should include Hello from Docker!.

How a Packer template is organized

A Packer template is one or more .pkr.hcl files in a directory. Packer reads all of them together, like Terraform does with .tf files. A template has four kinds of blocks:

BlockPurpose
packerRequired Packer version and the plugins (builders) to install
variableInputs that you can override on the command line or in a .pkrvars.hcl file
sourceWhere the build starts: the builder type and its settings, such as the base image
buildWhich sources to build, the provisioners that run inside them and the post-processors that run on the result

Step 1 - Installing Packer

HashiCorp publishes Packer in its APT repository. Add the signing key and the repository:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(. /etc/os-release && echo "$VERSION_CODENAME") main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

Install Packer:

sudo apt update
sudo apt install packer

Verify the installation:

packer version
Packer v1.14.2

Any 1.10 or later release works for this tutorial.

Step 2 - Creating the project

Create a directory for the template and a files/ subdirectory for content you will copy into the image:

mkdir -p ~/packer-web/files
cd ~/packer-web

Create the page that the image will serve:

nano files/index.html
<!DOCTYPE html>
<html>
  <head><title>Built with Packer</title></head>
  <body><h1>Built with Packer</h1></body>
</html>

Step 3 - Writing the template

Open a new template file:

nano web.pkr.hcl

Start with the packer block, which declares the Docker plugin. Packer plugins are installed separately from the Packer binary, and pinning a minimum version keeps builds predictable:

packer {
  required_version = ">= 1.10.0"

  required_plugins {
    docker = {
      source  = "github.com/hashicorp/docker"
      version = ">= 1.1.0"
    }
  }
}

Add two variables below it. The base image and the version of your image are the values that change between builds:

variable "base_image" {
  type        = string
  description = "Base image the build starts from."
  default     = "ubuntu:24.04"
}

variable "image_version" {
  type        = string
  description = "Version tag for the resulting image."
  default     = "1.0.0"
}

Next, add the source block. The Docker builder starts a container from base_image; commit = true saves that container as a new image when provisioning finishes. The changes list applies Dockerfile-style instructions to the committed image:

source "docker" "ubuntu" {
  image  = var.base_image
  commit = true

  changes = [
    "EXPOSE 80",
    "LABEL org.opencontainers.image.version=${var.image_version}",
    "ENTRYPOINT [\"nginx\", \"-g\", \"daemon off;\"]",
  ]
}

Setting ENTRYPOINT explicitly matters here. Packer runs the build container with /bin/sh as its entrypoint to keep it alive, and without an override the committed image would inherit that entrypoint instead of starting Nginx.

Finally, add the build block:

build {
  name    = "web"
  sources = ["source.docker.ubuntu"]

  provisioner "shell" {
    environment_vars = ["DEBIAN_FRONTEND=noninteractive"]
    inline = [
      "apt-get update",
      "apt-get install -y --no-install-recommends nginx",
      "rm -rf /var/lib/apt/lists/*",
    ]
  }

  provisioner "file" {
    source      = "files/index.html"
    destination = "/var/www/html/index.html"
  }

  post-processor "docker-tag" {
    repository = "local/web"
    tags       = [var.image_version, "latest"]
  }
}

The provisioners run in order inside the build container:

  • The shell provisioner installs Nginx and then deletes the APT package lists to keep the image small. Inline commands run with /bin/sh -e, so the build stops at the first failing command. The commands run as root because the base image's default user is root; on a VM build you would prefix them with sudo.
  • The file provisioner copies index.html into Nginx's default web root, which the package created in the previous step.

The docker-tag post-processor then tags the committed image as local/web:1.0.0 and local/web:latest.

Step 4 - Initializing and validating the template

packer init downloads the plugins declared in required_plugins. Run it once per project, and again after changing plugin versions:

packer init .

Packer prints an Installed plugin github.com/hashicorp/docker line with the version it downloaded into ~/.config/packer/plugins.

Format the files and check the template for errors before building:

packer fmt .
packer validate .
The configuration is valid.

packer fmt prints the names of the files it reformatted, or nothing if they were already formatted.

Step 5 - Building the image

Run the build from the project directory:

packer build .

Packer pulls the base image, starts a container, runs both provisioners and commits the result. The output streams the provisioner commands, prefixed with the build name, and ends with the list of artifacts:

Build 'web.docker.ubuntu' finished after 41 seconds 187 milliseconds.

==> Builds finished. The artifacts of successful builds are:
--> web.docker.ubuntu: Imported Docker image: sha256:3f1c9a7d2b10...
--> web.docker.ubuntu: Imported Docker image: local/web:1.0.0 with tags local/web:1.0.0 local/web:latest

The exact wording of the artifact lines depends on the plugin version. Confirm that the image exists with both tags:

docker images local/web
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
local/web    1.0.0     3f1c9a7d2b10   30 seconds ago   128MB
local/web    latest    3f1c9a7d2b10   30 seconds ago   128MB

Check that the entrypoint was set correctly:

docker inspect --format '{{json .Config.Entrypoint}}' local/web:1.0.0
["nginx","-g","daemon off;"]

Step 6 - Testing the image

Start a container from the new image and request the page:

docker run -d --name web-test -p 8080:80 local/web:1.0.0
curl -s http://localhost:8080
<!DOCTYPE html>
<html>
  <head><title>Built with Packer</title></head>
  <body><h1>Built with Packer</h1></body>
</html>

The container serves the file you baked into the image, with no configuration after start. Remove the test container:

docker rm -f web-test

Step 7 - Building new versions with variable files

To release a new version, change the inputs rather than the template. Create a variables file:

nano release.pkrvars.hcl
image_version = "1.1.0"

Build with it:

packer build -var-file=release.pkrvars.hcl .

For a single value you can use -var instead, for example packer build -var 'image_version=1.1.0' .. Files named *.auto.pkrvars.hcl in the project directory are loaded automatically without any flag.

The ubuntu:24.04 tag moves when Ubuntu publishes updated images, so two builds of the same template can start from different bases. For fully reproducible builds, pin the base image by digest, which you can read with docker images --digests ubuntu, and pass it as a variable:

packer build -var 'base_image=ubuntu:24.04@sha256:your_digest' -var-file=release.pkrvars.hcl .

Update the pinned digest deliberately, for example monthly, to pick up security fixes.

Using the same template for VM images

The build block does not depend on the builder. To produce a VM image instead of a container image, you add another plugin to required_plugins and another source block, and keep the provisioners:

  • github.com/hashicorp/qemu builds QCOW2 or raw disk images for KVM-based platforms. It boots a real virtual machine, so it needs hardware virtualization (/dev/kvm) on the build host.
  • github.com/hashicorp/amazon builds AMIs by launching a temporary EC2 instance.
  • Plugins for other clouds and hypervisors are listed in the Packer integrations catalog on the HashiCorp developer site.

Two differences matter when moving from containers to VMs. First, provisioners connect over SSH as a non-root user, so shell commands need sudo. Second, the last provisioner should remove per-machine identity so every server created from the image gets its own:

provisioner "shell" {
  inline = [
    "sudo cloud-init clean --logs --seed",
    "sudo truncate -s 0 /etc/machine-id",
    "sudo rm -f /etc/ssh/ssh_host_*",
  ]
}

On first boot, cloud-init and systemd then generate a new machine ID and new SSH host keys. When a template has several sources, packer build -only='web.docker.ubuntu' . builds just one of them.

Troubleshooting

Error: Missing plugins or Unknown source type "docker": the plugin is not installed. Run packer init . in the project directory.

Got permission denied while trying to connect to the Docker daemon socket: your user is not in the docker group, or you have not logged in again since adding it. Check with groups.

A provisioner fails halfway: Packer removes the build container by default, which makes debugging hard. Run the build with PACKER_LOG=1 packer build -on-error=ask .; on failure Packer pauses and lets you inspect the running container (docker ps, then docker exec -it CONTAINER_ID sh) before choosing to clean up.

The container exits immediately after docker run: check the entrypoint with docker inspect as shown in Step 5. If it is ["/bin/sh"], the ENTRYPOINT line in changes is missing or malformed.

Conclusion

You installed Packer, wrote an HCL2 template with variables, provisioners and a tagging post-processor, and built and tested a versioned Nginx image. Because the image is defined in code, you can rebuild it with the latest security updates or a new application version with one command, and review every change in Git.

As next steps, run packer fmt -check and packer validate in your CI pipeline and build images on every merge, push the images to a registry with the docker-push post-processor, and move provisioning logic into Ansible with the Ansible provisioner plugin once the shell steps grow.