How to Use Packer to Create a Docker Image: Automated Image Building and Provisioning Guide

How to Use Packer to Create a Docker Image

When I first started automating infrastructure, I built Docker images the way most people do — I hand-wrote a Dockerfile, ran docker build, and called it a day. That workflow works fine until you need the same image-building logic to also produce an AWS AMI, an Azure managed image, or a Vagrant box from a single source of truth. That’s the exact problem HashiCorp Packer solves, and in this guide I’ll walk you through everything I’ve learned about using Packer specifically with its Docker builder — from the fundamentals of what Packer actually does, all the way to production CI/CD pipelines.

What Packer Actually Is (and Why It’s Different from a Dockerfile)

Packer is an open-source tool from HashiCorp that automates the creation of machine and container images from a single, declarative template. Instead of manually running commands against a Dockerfile, you describe your build in HCL2 (HashiCorp Configuration Language) and let Packer orchestrate the whole lifecycle: spinning up a source container, running provisioners against it, committing the result, tagging it, and optionally pushing it to a registry.

The key distinction I always explain to people new to this: a Dockerfile describes how to build one specific image type. Packer describes a build pipeline that can target multiple platforms using the same provisioning scripts. If your organization builds both containers and VM images (EC2 AMIs, GCE images, VMware templates) and wants consistent configuration management (via shell scripts, Ansible, or Chef) across all of them, Packer lets you reuse provisioners instead of maintaining separate logic per platform.

Prerequisites

Before we start, make sure you have:

  • Docker installed and running (docker version should return without errors)
  • Packer installed — download it from HashiCorp’s releases page or install via your package manager
  • Basic familiarity with the command line and Docker concepts (images, containers, layers)

Verify your Packer installation:

packer version

Expected output (version numbers will vary):

Packer v1.11.2

Understanding the Docker Builder

Packer’s Docker builder works differently from other builders (like the AWS AMI builder) because Docker containers are ephemeral and don’t have a traditional “boot from image, provision, snapshot” cycle in the same sense as a VM. Instead, the Docker builder:

  1. Pulls (or uses) a base image and starts a container from it
  2. Mounts a temporary directory so it can copy files and run provisioners inside the running container
  3. Runs your provisioners (shell scripts, file uploads, Ansible, etc.) against that running container
  4. Either commits the container as a new image (commit = true) or exports the container’s filesystem as a tarball (export_path)

This distinction between commit and export_path trips a lot of people up initially, so let’s break it down:

  • commit = true — Packer commits the container’s state as a new Docker image directly into your local Docker daemon, similar to docker commit. This preserves image layers and lets you use post-processors like docker-tag to tag it.
  • export_path — Packer exports the container’s full filesystem as a flat tarball, similar to docker export. This produces a single-layer filesystem archive rather than a layered image, useful when you want a portable rootfs rather than a runnable Docker image.

For most Docker image-building use cases, you’ll want commit = true.

Writing Your First Packer Template

Create a directory for your project:

mkdir packer-docker-demo && cd packer-docker-demo

Create a file named docker-ubuntu.pkr.hcl:

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

variable "docker_image" {
  type    = string
  default = "ubuntu:jammy"
}

source "docker" "ubuntu" {
  image  = var.docker_image
  commit = true
}

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

  provisioner "shell" {
    inline = [
      "apt-get update",
      "apt-get install -y curl ca-certificates",
      "echo 'Provisioned by Packer' > /etc/motd"
    ]
  }

  post-processors {
    post-processor "docker-tag" {
      repository = "my-org/packer-demo"
      tags       = ["latest", "1.0.0"]
    }
  }
}

Let’s break down each block:

  • packer { required_plugins { ... } } — declares that this template needs the Docker plugin, and pins a minimum version so builds are reproducible across machines.
  • variable "docker_image" — a parameterized input so you can override the base image without editing the template (packer build -var 'docker_image=ubuntu:noble' .).
  • source "docker" "ubuntu" — the builder definition. image is the base image to pull, and commit = true tells Packer to commit the provisioned container as a new image rather than exporting a tarball.
  • build { ... } — ties sources to provisioners and post-processors.
  • provisioner "shell" — runs inline shell commands inside the running container. You could just as easily use provisioner "ansible" or provisioner "file" here.
  • post-processors { post-processor "docker-tag" { ... } } — after the build, tags the committed image with a repository name and one or more tags.

Initializing and Validating the Template

Packer requires you to initialize plugins before building, similar to Terraform:

packer init docker-ubuntu.pkr.hcl

Expected output:

Installed plugin github.com/hashicorp/docker v1.0.9 in "~/.config/packer/plugins/..."

Always validate your template before building — this catches syntax errors and misconfigurations early:

packer validate docker-ubuntu.pkr.hcl

Expected output:

The configuration is valid.

Running the Build

Now build the image:

packer build docker-ubuntu.pkr.hcl

You’ll see output like this (abbreviated):

docker-example.docker.ubuntu: output will be in this color.

==> docker-example.docker.ubuntu: Creating a temporary directory for sharing data...
==> docker-example.docker.ubuntu: Pulling Docker image: ubuntu:jammy
==> docker-example.docker.ubuntu: Starting docker container...
    docker-example.docker.ubuntu: Run command: docker run -v /tmp/packer-...:/packer-files -d -i -t ubuntu:jammy /bin/bash
==> docker-example.docker.ubuntu: Provisioning with shell script...
    docker-example.docker.ubuntu: Get:1 http://archive.ubuntu.com/ubuntu jammy InRelease [270 kB]
    ...
==> docker-example.docker.ubuntu: Committing the container
    docker-example.docker.ubuntu: Image ID: sha256:1a2b3c4d5e6f...
==> docker-example.docker.ubuntu: Killing the container...
==> docker-example.docker.ubuntu: Running post-processor: docker-tag
==> docker-example.docker.ubuntu (docker-tag): Tagging image: sha256:1a2b3c4d5e6f...
==> docker-example.docker.ubuntu (docker-tag): Repository: my-org/packer-demo
==> docker-example.docker.ubuntu (docker-tag): Tags: latest, 1.0.0
Build 'docker-example.docker.ubuntu' finished after 42 seconds.

==> Builds finished. The artifacts of successful builds are:
--> docker-example.docker.ubuntu: Imported Docker image: my-org/packer-demo:latest
--> docker-example.docker.ubuntu: Imported Docker image: my-org/packer-demo:1.0.0

Confirm the image landed in your local Docker daemon:

docker images my-org/packer-demo
REPOSITORY          TAG       IMAGE ID       CREATED          SIZE
my-org/packer-demo  latest    1a2b3c4d5e6f   30 seconds ago   131MB
my-org/packer-demo  1.0.0     1a2b3c4d5e6f   30 seconds ago   131MB

Pushing the Image to a Registry

Chain a docker-push post-processor after docker-tag to publish automatically:

  post-processors {
    post-processor "docker-tag" {
      repository = "my-org/packer-demo"
      tags       = ["latest", "1.0.0"]
    }
    post-processor "docker-push" {}
  }

Note the post-processors (plural) block — this creates a pipeline where the output of docker-tag feeds directly into docker-push. If you instead declare two separate post-processors blocks, each one operates independently on the raw builder artifact, not on each other’s output. This distinction matters a lot in practice, since it’s the difference between “tag then push the tagged image” and “tag AND push run in parallel against the original untagged artifact.”

Make sure you’re authenticated to your registry first:

docker login

Using the Export Path Instead of Commit

If you want a portable rootfs tarball instead of a committed image (useful for feeding into docker-import on another machine, or for base-layer distribution), swap your source configuration:

source "docker" "ubuntu-export" {
  image       = "ubuntu:jammy"
  export_path = "ubuntu-provisioned.tar"
}

Then, elsewhere or later, import it:

packer build -only=docker-example.docker.ubuntu-export docker-ubuntu.pkr.hcl
docker import ubuntu-provisioned.tar my-org/imported-image:latest

Internal Working: What Happens Under the Hood

Understanding the internals helps you debug failures faster. When you run packer build:

  1. Packer’s Docker plugin shells out to the local Docker CLI/daemon (it doesn’t reimplement the container runtime — it wraps docker run, docker cp, docker commit, and docker export).
  2. It creates a temporary shared directory and mounts it into the container so provisioners can copy files in and out without needing SSH.
  3. Provisioners execute via docker exec-style invocation against the running container.
  4. On success, commit triggers a docker commit <container_id>-equivalent call, producing a new image layer stack; export_path instead runs docker export and writes the flattened tarball to disk.
  5. The container is stopped and removed regardless of which artifact mode you use, so you never end up with orphaned containers after a successful build.

Because it relies on the Docker daemon’s own commit mechanism, image layer history and caching behave exactly as they would with a manual docker commit — this is why Packer-built images can sometimes be bulkier than Dockerfile multi-stage builds unless you’re careful about what you provision.

Real-World DevOps Workflow: CI/CD Integration

In practice, I use Packer inside CI pipelines when the same base-image provisioning logic needs to produce both a container image and a VM image for different environments (e.g., containerized microservices plus a golden AMI for a legacy VM fleet). A typical GitLab CI job looks like this:

build-image:
  stage: build
  image: hashicorp/packer:1.11
  script:
    - packer init docker-ubuntu.pkr.hcl
    - packer validate docker-ubuntu.pkr.hcl
    - packer build -var "docker_image=ubuntu:jammy" docker-ubuntu.pkr.hcl
  only:
    - tags

This gives you version-controlled, reproducible builds that trigger only on tagged releases, which keeps your registry from filling up with untagged, ad hoc images.

Security Considerations

A few things I always double-check when provisioning images with Packer:

  • Never bake secrets into the image. Provisioners run inside the container, and anything written to disk becomes part of the committed layer history — including files you later delete, since earlier layers still contain them. Use build-time-only mounts or pass secrets via environment variables scoped to CI, not to the template.
  • Pin base image tags. Using ubuntu:latest means your “reproducible” build isn’t reproducible at all. Pin to a specific tag or digest.
  • Scan the resulting image. Run docker scout or trivy image my-org/packer-demo:latest as a post-build CI step before pushing to production registries.
  • Minimize installed packages. Every apt-get install in your provisioner is attack surface in the final image; clean up package caches in the same provisioner step to avoid bloating layers.

Troubleshooting Common Issues

  • “Error initializing: no source found for docker” — you forgot packer init, or you’re on an old Packer binary without plugin auto-installation. Run packer init first.
  • Container exits immediately during provisioning — some minimal base images (like scratch or alpine without a shell) don’t have a shell for the shell provisioner to run against. Confirm the image contains /bin/sh.
  • Commit succeeds but the image is missing metadata (ENTRYPOINT, CMD, EXPOSE) — the Docker builder’s commit does not preserve those Dockerfile-style instructions by default; you need a changes array on docker-import, or a dedicated Dockerfile-metadata post-processor, to set them.
  • Push fails with authentication errors — confirm docker login succeeded against the exact registry host referenced in your repository field, including any namespace prefix.

Summary

Packer gives you a declarative, version-controlled way to build Docker images (and other artifact types) using a consistent provisioning workflow. The Docker builder wraps the Docker daemon’s own run, commit, and export primitives, so understanding those primitives directly demystifies almost every Packer behavior you’ll encounter. Start simple with commit = true and a shell provisioner, add docker-tag and docker-push post-processors once you’re happy with the build, and treat the whole template as code — validated, reviewed, and run through CI just like the rest of your infrastructure.

References

  • HashiCorp Packer Documentation: https://developer.hashicorp.com/packer
  • Packer Docker Plugin (Builder): https://developer.hashicorp.com/packer/integrations/hashicorp/docker/latest/components/builder/docker
  • Packer Docker Tag Post-Processor: https://developer.hashicorp.com/packer/integrations/hashicorp/docker/latest/components/post-processor/docker-tag
  • Docker Official Documentation: https://docs.docker.com/
  • Docker Commit Reference: https://docs.docker.com/reference/cli/docker/container/commit/

Total
0
Shares

Leave a Reply

Previous Post
How to Versioning an Image with Tags

How to Version an Image with Tags: Docker Image Tagging Strategies and Best Practices

Next Post
How to Find the IP Address of a Docker Container

How to Find the IP Address of a Docker Container: Quick Methods and Troubleshooting Tips

Related Posts