Packer is a tool for creating identical machine images for multiple platforms from a single source configuration. Packer can build machine images for multiple platforms in parallel, and it can use tools like Ansible, Chef or Puppet to install software onto the image.
A machine image is a single static unit that contains a pre-configured operating system and installed software which is used to quickly create new running machines. Machine image formats change for each platform. Some examples include AMIs for EC2, VMDK/VMX files for VMware, OVF exports for VirtualBox, etc.
In this guide we build a base image: an AMI with your common tooling already baked in, which you then use as the starting point for more specific images. If you would rather see an image built for a specific role, along with a Terraform test harness to launch it, see How to Build an AWS EC2 Machine Image (AMI) With Packer.
Related content:
- How to Build an AWS EC2 Machine Image (AMI) With Packer
- How to use Terraform to Launch an AWS EC2 Instance
- How to use Terraform AWS EC2 user_data - aws_instance
What changed since this guide was written
This guide originally built a CentOS 7 image from a JSON template. Three things have changed, and all of them break the original steps:
- CentOS 7 reached end of life on 30 June 2024. Its repositories were moved to
vault.centos.organd themirrorlist.centos.orghostname was removed from DNS, so ayum installinside a CentOS 7 build now fails outright. The examples below use Rocky Linux 9, a drop-in RHEL-compatible successor. - Packer templates are written in HCL2. JSON templates still parse but are deprecated and receive no new features. HCL2 has been the recommended format since Packer 1.7.
- Packer no longer bundles its builders. Since Packer 1.11, official plugins such as
amazonare not included in the binary. You declare the plugin in the template and runpacker initto fetch it.
Note also that since August 2023, HashiCorp licenses Packer under the Business Source License (BUSL) 1.1 rather than the MPL. It is still free to use for building your own images, but it is no longer strictly “open source”.
Building a base image
File ./base.pkr.hcl
packer {
required_plugins {
amazon = {
version = "~> 1.8"
source = "github.com/hashicorp/amazon"
}
}
}
variable "aws_profile" {
type = string
default = "citizix"
}
variable "aws_region" {
type = string
default = "eu-west-3"
}
source "amazon-ebs" "base" {
profile = var.aws_profile
region = var.aws_region
vpc_id = "vpc-xxxxx"
subnet_id = "subnet-xxxxx"
associate_public_ip_address = true
instance_type = "t3.medium"
ssh_username = "rocky"
ami_name = "citizix-base-${formatdate("YYYYMMDDhhmm", timestamp())}"
ami_description = "Up to date Rocky Linux 9 base image for citizix"
source_ami_filter {
filters = {
name = "Rocky-9-EC2-Base-9.*x86_64*"
}
owners = ["aws-marketplace"]
most_recent = true
}
run_tags = {
Name = "packer-builder-base"
Tool = "Packer"
Author = "citizix-devops"
}
tags = {
Tool = "Packer"
Author = "citizix-devops"
}
}
build {
sources = ["source.amazon-ebs.base"]
provisioner "shell" {
script = "./setup-base.sh"
}
}
Two changes here are worth explaining, because they are the ones most likely to bite you.
The original template pinned a specific source_ami (ami-0cb72d2e599cffbf9). AMI IDs are region-specific and get deregistered over time, so a hardcoded ID rots quickly and fails with an unhelpful “invalid AMI” error. The source_ami_filter block above instead resolves the most recent matching image at build time, which keeps working as the upstream publisher releases new versions.
The ami_name now includes a timestamp. AMI names must be unique within a region, so a fixed name like citizix-centos-7 fails on the second build unless you deregister the first image.
The provisioner script
File ./setup-base.sh
#!/bin/bash -xe
sudo dnf -y update
sudo dnf install -y epel-release
sudo dnf install -y dnf-plugins-core
sudo dnf install -y rsync vim telnet htop git monit jq zip unzip
# AWS CLI v2 - the pip-installable `awscli` package is v1, which is superseded.
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp
sudo /tmp/aws/install
rm -rf /tmp/awscliv2.zip /tmp/aws
# Docker Engine and the Compose v2 plugin, which replace `pip install docker-compose`.
sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable --now docker
A few notes on what changed from the original script.
The shebang was #/bin/bash — missing the !. That line is just a comment, so the script ran under whatever shell the provisioner happened to use, and errors did not abort the build. #!/bin/bash -xe fixes the shebang and makes the script echo each command (-x) and fail the build on the first error (-e), which is what you want in an image build.
pip install docker-compose installed Compose v1, which is written in Python, is no longer maintained, and is invoked as docker-compose (with a hyphen). Compose v2 ships as a Docker plugin and is invoked as docker compose. Because neither the AWS CLI nor Compose is installed through pip any more, the python-pip, gcc and python-devel build dependencies are gone too.
SELinux is left enforcing. The original script contained this line:
sudo sed -i 's/SELINUX=enforcing/SELINUX=permisive/g' /etc/selinux/config
That has two problems. The value is misspelled (permisive), which is not a valid setting, so the edit did not do what it looked like it did. More importantly, disabling SELinux in a base image bakes a weakened security posture into every instance launched from it, and every image derived from it. Leave SELinux enforcing and grant the specific access a workload needs instead — label a custom directory rather than turning the policy off:
sudo dnf install -y policycoreutils-python-utils
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/www(/.*)?"
sudo restorecon -Rv /srv/www
Building the image
First, fetch the amazon plugin declared in the required_plugins block. This is needed once per template, and again whenever you change the plugin version:
packer init base.pkr.hcl
Then format and validate the template before you spend money on a build:
packer fmt base.pkr.hcl
packer validate base.pkr.hcl
Finally, build the image:
packer build base.pkr.hcl
# You can pass more args
packer build -debug -var-file=vars.pkrvars.hcl base.pkr.hcl
Packer launches a temporary EC2 instance, runs the provisioner script against it, stops and destroys the instance, and registers the AMI. The AMI ID is printed at the end.
Still on a JSON template? Packer can convert it for you with
packer hcl2_upgrade base-ce-7.json. JSON templates cannot usepacker init, so if you keep one you must install the plugin by hand withpacker plugins install github.com/hashicorp/amazon.
Conclusion
In this guide we built a reusable base AWS AMI with Packer using an HCL2 template, resolved the source image with an AMI filter instead of a hardcoded ID, and baked in common tooling with a shell provisioner.
From here you can use this base AMI as the source_ami_filter target of more specific images — a web server, an application node — so that shared tooling and patches are built once and inherited everywhere.