The most surprising thing about building Vagrant boxes with Packer is how much of the "magic" is actually just well-understood system administration under the hood, combined with clever templating.
Let’s see it in action. Imagine we want to build a minimal Ubuntu 22.04 Vagrant box, pre-configured with Docker.
First, we need a Packer template. This is a JSON or HCL file that tells Packer what to build and how.
{
"variables": {
"iso_url": "https://releases.ubuntu.com/22.04/ubuntu-22.04.3-live-server-amd64.iso",
"iso_checksum": "sha256:4942733753d64f1e2119532586170d2541060120b0b815f805c39370010a3831",
"vm_name": "ubuntu-2204-docker"
},
"builders": [
{
"type": "virtualbox",
"iso_url": "{{user `iso_url`}}",
"iso_checksum": "{{user `iso_checksum`}}",
"iso_checksum_type": "sha256",
"guest_os_type": "Ubuntu_64",
"vm_name": "{{user `vm_name`}}",
"headless": true,
"disk_size": "20480",
"memory": "1024",
"cpus": "1",
"ssh_username": "vagrant",
"ssh_password": "password",
"shutdown_command": "echo {{user `ssh_password`}} | sudo -S shutdown -P now",
"post_install_commands": [
"echo 'Acquire::Force-Broken-Links=true;' | sudo tee /etc/apt/apt.conf.d/99force-broken-links",
"sudo apt-get update",
"sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y",
"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y linux-headers-{{kernel_version}} curl gnupg",
"curl -fsSL https://get.docker.com -o get-docker.sh",
"sudo sh get-docker.sh",
"sudo usermod -aG docker vagrant"
]
}
],
"provisioners": [
{
"type": "shell",
"inline": [
"echo 'Setting up Vagrant keys...'",
"wget -q https://raw.githubusercontent.com/hashicorp/vagrant/main/keys/vagrant.pub -O ~vagrant/.ssh/authorized_keys",
"chown -R vagrant:vagrant ~vagrant/.ssh",
"chmod 0700 ~vagrant/.ssh",
"chmod 0600 ~vagrant/.ssh/authorized_keys",
"echo 'Cleaning up...'",
"sudo apt-get clean",
"sudo rm -rf /var/lib/apt/lists/*",
"sudo rm -rf /tmp/*",
"sudo rm -f /get-docker.sh",
"echo 'Zeroing free space...'",
"sudo dd if=/dev/zero of=/EMPTY bs=1M || echo 'dd exit code $? ignored'",
"sudo rm -f /EMPTY",
"sync"
]
}
]
}
When you run packer build your-template.json, Packer does a few things:
- Creates a VM: It spins up a VirtualBox VM using the specified ISO.
- Automates Installation: It uses
ssh_usernameandssh_password(for the initial boot, before Vagrant is set up) to log in and runpost_install_commands. These commands automate the OS install, install necessary packages likelinux-headersandcurl, and crucially, download and install Docker. TheDEBIAN_FRONTEND=noninteractiveis key to preventing interactive prompts duringaptoperations. - Runs Provisioners: After the OS is installed and the VM reboots, Packer uses SSH (now with the
vagrantuser and its default key) to run theprovisioners. This is where we copy the official Vagrant public SSH key intoauthorized_keysfor thevagrantuser, ensuring Vagrant can manage the box. We also clean up package caches, temporary files, and zero out free disk space to make the final box smaller. - Packages the Box: Finally, Packer exports the VM as a
.boxfile, which is essentially a tarball containing the VM’s disk image and aVagrantfilethat tells Vagrant how to configure it.
To use this box, you’d run:
vagrant init ubuntu-docker https://example.com/path/to/your/ubuntu-2204-docker.box
vagrant up
The Vagrantfile Packer generates for the box will typically look something like this:
# -*- mode: ruby -*-
# vi: set ft=ruby :
Vagrant.configure("2") do |config|
config.vm.box = "ubuntu-2204-docker"
config.vm.box_url = "https://example.com/path/to/your/ubuntu-2204-docker.box"
# Configure the provider
config.vm.provider "virtualbox" do |vb|
# Display the VirtualBox GUI when booting the machine
vb.gui = false
# Customize the amount of memory on the VM:
vb.memory = "1024"
# Customize the number of CPUs:
vb.cpus = "1"
end
# Share your home directory to the VM.
# config.vm.synced_folder "~", "/home/vagrant/host"
# Forward an additional port to the host.
# config.vm.network "forwarded_port", guest: 80, host: 8080
# Create a private network, which allows your VM to be accessible
# via an IP address.
# config.vm.network "private_network", ip: "192.168.33.10"
# Create a public network, which generally can be accessible from outside
# your network.
# config.vm.network "public_network"
# Configure a firewall to allow HTTP traffic on port 80
# config.vm.forward_port 80, 8080
# If you want to create a very simple shared folder, uncomment the next line
# config.vm.synced_folder ".", "/vagrant", disabled: true
# Provider-specific configuration so that Vagrant knows how to
# provision this box.
# config.vm.provider "virtualbox" do |vb|
# # Display the VirtualBox GUI when booting the machine
# vb.gui = true
# # Autodetect any ports you are forwarding
# vb.autodetect_ports = true
# end
# Share an additional folder into the user's home directory.
# config.vm.synced_folder "../data", "/vagrant_data"
# If you are using CentOS, you may be required to disable the "puppet"
# provisioning if you are not using it.
# config.vm.provision "shell", inline: <<-SHELL
# sudo yum -y update
# SHELL
end
The Vagrantfile is crucial because it defines the VM’s network settings, shared folders, and any provisioning steps Vagrant should run after the box is imported. Packer embeds a default Vagrantfile into the box, which vagrant init then copies and potentially modifies.
The post_install_commands in Packer run during the OS installation, while the provisioners run after the OS is installed and the VM is ready for SSH. This distinction is important for tasks that require a fully booted OS, like setting up SSH access for the vagrant user.
One of the most subtle but powerful aspects of this process is how Packer handles the shutdown_command. For VirtualBox, it’s echo {{user ssh_password}} | sudo -S shutdown -P now. This command ensures the VM is shut down gracefully after Packer has finished its automated installation and configuration steps, but before it’s exported as a box. A clean shutdown is vital for creating a stable and portable VM image. Without it, the exported disk image might have filesystem inconsistencies that could lead to boot failures or data corruption when the box is used by others. Packer waits for this command to succeed before proceeding to package the VM.
The next step after building and publishing your own base boxes is learning about Vagrant plugins, which extend Vagrant’s functionality with custom providers, provisioners, and more.