Packer’s Docker builder doesn’t actually "build" a container image in the way you might expect; it uses Docker itself to build it for you.

Let’s see how this plays out. Imagine you want to build an image for a simple web server.

FROM alpine:latest
RUN apk add --no-cache nginx
COPY index.html /var/www/localhost/htdocs/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

And you have a basic index.html:

<!DOCTYPE html>
<html>
<head>
<title>Hello from Packer!</title>
</head>
<body>
<h1>Hello Packer!</h1>
</body>
</html>

Here’s a Packer template to build this into a Docker image:

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

source "docker" "webserver" {
  image = "my-dockerhub-user/my-webserver:latest"
  build_context = "."
  dockerfile = "Dockerfile"
  commit = true
  push = false
}

build {
  sources = ["source.docker.webserver"]

  provisioner "shell" {
    inline = [
      "echo 'Built by Packer!'"
    ]
  }
}

When you run packer build your-template.pkr.hcl, Packer doesn’t spin up its own container environment and install nginx. Instead, it tells Docker: "Here’s a Dockerfile, here’s my build context (the directory containing the Dockerfile and index.html), and build me an image named my-dockerhub-user/my-webserver:latest." Docker then does all the heavy lifting: pulling the alpine:latest base image, running the RUN apk add command, copying your index.html, and so on. Packer’s role is to orchestrate this Docker build process and then, if commit = true (as it is here), it will tag the resulting image and optionally push it.

The core problem Packer’s Docker builder solves is making image building repeatable and platform-agnostic within your CI/CD pipeline. Instead of manually running docker build, you define your image build in a declarative HCL file. This template is then the single source of truth for your image, ensuring that anyone can reproduce it exactly. It abstracts away the docker build command, allowing you to integrate image creation seamlessly into automated workflows. You define your base image, any necessary dependencies, configuration files, and the final commands to run, all within the Packer template.

Internally, when you specify build_context = ".", Packer packages up the current directory and sends it to the Docker daemon. The dockerfile = "Dockerfile" directive tells the daemon which file in that context to use as the instructions. If you have a more complex setup, you can specify a different path for dockerfile or use build_context to point to a directory containing your Dockerfile and any other assets. The commit = true option is crucial; after Docker builds the image, Packer will create a new, tagged image from the container that was used for the build, effectively saving the state of that container as a new image. push = true then sends this committed image to a registry.

The most surprising aspect is how Packer uses Docker’s build cache to your advantage. If you’re building an image and a layer hasn’t changed since the last build, Docker will reuse that cached layer, making subsequent builds significantly faster. Packer, by orchestrating this, inherits that speed benefit without needing any special configuration for it. It’s just how Docker works, and Packer leverages it.

The commit option in the docker source block is what creates the final image. When commit is true, Packer starts a container from the base image, executes the Dockerfile instructions within that container, and then uses docker commit on that running container. This creates a new image that represents the final state. If you omit commit or set it to false, Packer will perform the build steps but won’t produce a tagged image artifact.

The next hurdle you’ll likely face is managing multiple Dockerfile variants or building images for different architectures.

Want structured learning?

Take the full Packer course →