How to Build an AWS EC2 Machine Image (AMI) With Packer

Build an AWS EC2 AMI with Packer: install Packer, write an HCL2 template with the amazon-ebs source and a shell provisioner, then test the AMI with Terraform.

An Amazon Machine Image (AMI) is a supported and maintained image provided by AWS that provides the information required to launch an instance. You must specify an AMI when you launch an instance. You can launch multiple instances from a single AMI when you require multiple instances with the same configuration. You can use different AMIs to launch instances when you require instances with different configurations.

An AMI provides the information required to launch an instance, which may include Base Operating system, application dependencies, and other runtime libraries required.

An AMI includes the following:

  • One or more Amazon Elastic Block Store (Amazon EBS) snapshots, or, for instance-store-backed AMIs, a template for the root volume of the instance (for example, an operating system, an application server, and applications).
  • Launch permissions that control which AWS accounts can use the AMI to launch instances.
  • A block device mapping that specifies the volumes to attach to the instance when it’s launched.

Related content:

What is Packer?

Packer is a tool for creating golden images for multiple platforms from a single source configuration. It is lightweight, runs on every major operating system, and is highly performant, creating machine images for multiple platforms in parallel. It is made by HashiCorp and gives you the flexibility of building your custom AMI for use in the AWS EC2 platform.

Two things about Packer have changed since this guide was first published, and both affect the examples below:

  • Templates are written in HCL2, not JSON. JSON templates still parse, but they are deprecated and receive no new features. HCL2 has been the recommended format since Packer 1.7, and it is what this guide now uses.
  • Packer no longer ships with builders built in. Since Packer 1.11, official plugins such as amazon are no longer bundled in the binary. You declare the plugin in your template and run packer init to fetch it, as shown below.

It is also worth knowing that since August 2023 HashiCorp licenses Packer under the Business Source License (BUSL) 1.1 rather than the MPL. It remains free to use for building your own images — the restriction only targets competing commercial offerings — but it is no longer strictly “open source”.

What are Packer provisioners?

Provisioners are components of Packer that install and configure software within a running machine prior to that machine being turned into a static image. They perform the major work of making the image contain useful software. Example provisioners include shell scripts, Ansible, Chef, Puppet, etc.

Ensure packer is installed

Since we are using packer, we have to make sure that it is installed before proceeding.

If you are an Ubuntu or Debian user, use these commands to install packer:

wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor \
  -o /usr/share/keyrings/hashicorp-archive-keyring.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \
  | sudo tee /etc/apt/sources.list.d/hashicorp.list

sudo apt update && sudo apt install packer

Older versions of this guide (and many others online) used curl ... | sudo apt-key add -. apt-key is deprecated and has been removed from current APT releases, so the signed-by= keyring above is the approach to use on Ubuntu 22.04+ and Debian 12+.

If you are using an RHEL based OS like Rocky Linux or AlmaLinux:

sudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo dnf -y install packer

If you are using mac or homebrew package manager, use this command to install:

brew tap hashicorp/tap
brew install hashicorp/tap/packer

For other operating systems, check out the Packer install page.

Confirm the version you ended up with — the examples in this guide were tested against Packer 1.15:

$ packer version

Packer v1.15.4

Create packer project

Create a project directory and switch to it:

mkdir packer
cd packer

Under the project, create a folder called scripts that we will use for our provisioner.

mkdir scripts

This is my current directory structure

$ tree packer

packer
└── scripts

2 directories, 0 files

Creating Packer templates

Packer reads its configuration from an HCL2 file (JSON is still parsed but deprecated). We are going to define the required plugin, variables, a source and a build block.

Let us create an example nginx web server. Open a webserver.pkr.hcl template file using your favourite text editor, I am using vim in my case:

vim webserver.pkr.hcl

Add this content to the file:

packer {
  required_plugins {
    amazon = {
      version = "~> 1.8"
      source  = "github.com/hashicorp/amazon"
    }
  }
}

variable "name_prefix" {
  type    = string
  default = "webserver"
}

variable "aws_region" {
  type    = string
  default = env("AWS_REGION")
}

variable "subnet_id" {
  type    = string
  default = "subnet-xxxxx"
}

variable "vpc_id" {
  type    = string
  default = "vpc-xxxxx"
}

source "amazon-ebs" "webserver" {
  region                      = var.aws_region
  instance_type               = "t3.micro"
  ssh_username                = "rocky"
  subnet_id                   = var.subnet_id
  vpc_id                      = var.vpc_id
  ami_name                    = "${var.name_prefix}-v${formatdate("YYYYMMDDhhmm", timestamp())}"
  ami_description             = "Citizix Web Server Image"
  associate_public_ip_address = true

  ami_block_device_mappings {
    device_name           = "/dev/sda1"
    volume_size           = 8
    delete_on_termination = true
  }

  source_ami_filter {
    filters = {
      name = "Rocky-9-EC2-Base-9.*x86_64*"
    }
    owners      = ["aws-marketplace"]
    most_recent = true
  }
}

build {
  sources = ["source.amazon-ebs.webserver"]

  provisioner "shell" {
    scripts = ["scripts/webserver.sh"]
  }
}

The packer block declares the amazon plugin. This is the part that did not exist in the original JSON version of this guide: since Packer 1.11 the builders are no longer bundled in the Packer binary, so without this block (and the packer init in the next section) Packer will not know what amazon-ebs is.

In the variable blocks, set the required variables. In my case I am setting the image name, AWS region which is obtained from the env variable AWS_REGION, subnet id and vpc id.

In the source block, set the AWS properties for the source image and the name of the image to build. The source_ami_filter will pick the most recent Rocky Linux 9 image to use for the build — the original guide built on Rocky Linux 8, which is now a generation behind. Consult the amazon-ebs builder documentation for more details.

In the build block, provide the paths to your scripts to be executed during the build. In my case, I am defining a script in scripts/webserver.sh.

Still on a JSON template? Packer can convert it for you with packer hcl2_upgrade webserver.json. JSON templates are deprecated, do not receive new features, and cannot use packer init — if you keep one, install the plugin manually with packer plugins install github.com/hashicorp/amazon.

Create provisioners scripts

Finally, let us define the script that will be executed when the ami is being build. In our case, since we want to set up nginx to serve basic content, we will install nginx and create a hello world file to be served.

Open the script file with your text editor:

sudo vim scripts/webserver.sh

Add this content to the file:

#!/bin/bash -xe

sudo dnf -y update

sudo dnf install -y epel-release
sudo dnf install -y vim wget curl telnet htop

sudo dnf install -y nginx

sudo bash -c "cat > /usr/share/nginx/html/hello.html <<EOC
Hello world from Citizix.
EOC"

sudo systemctl enable --now nginx

Two details in that script are worth calling out.

enable --now both starts nginx and enables it at boot, so instances launched from the AMI come up already serving. (--now is a flag of systemctl enable, not of systemctl start — a common mix-up.)

SELinux is left enforcing. An earlier version of this guide ran setenforce 0 and flipped /etc/selinux/config to permissive, which bakes a weakened security posture into every instance launched from the image. It is not needed here: nginx serving from its default docroot (/usr/share/nginx/html) works fine under enforcing SELinux, and the hello.html we create inherits that directory’s httpd_sys_content_t label. If you later serve content from a custom path, label it instead of disabling SELinux:

sudo dnf install -y policycoreutils-python-utils
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/www(/.*)?"
sudo restorecon -Rv /srv/www

And if nginx needs to reach a backend (for example when used as a reverse proxy), flip the boolean rather than the whole policy:

sudo setsebool -P httpd_can_network_connect 1

Run the packer build

First ensure that you are logged in to aws. I have a profile called citizix where I have added my credentials. The commands below will set the AWS region and citizix profile to be active.

export AWS_REGION=eu-west-1
export AWS_PROFILE=citizix

Next, download the plugins declared in the required_plugins block. This only needs to be done once per template (and again whenever you change the plugin version):

packer init webserver.pkr.hcl

It is also good practice to format and validate the template before a build:

packer fmt webserver.pkr.hcl
packer validate webserver.pkr.hcl

Next let us build our ami. We can save the build log to build-artifact.log so we can refer to it in future.

packer build webserver.pkr.hcl | tee build-artifact.log

Once done with provisioning, packer will stop and destroy the temporary instance used, then create an AMI. The AMI ID is printed at the end.

Testing AMI Created

In this section, I’ll use Terraform to provision a new instance with created AMI. The same can be done from AWS console. We are going to create an AWS instance using the image we build. We will use terraform to achieve this.

Before proceeding, ensure that you have terraform installed. confirm with this command:

$ terraform --version

Terraform v1.15.8
on darwin_arm64

Create terraform projects directory.

mkdir terraform

We are querying the latest ami matching the webserver ami we created then using it. Add these content to main.tf.

provider "aws" {
  region = "eu-west-1"
}

data "aws_ami" "web" {
  most_recent = true
  owners      = ["self"]

  filter {
    name   = "name"
    values = ["webserver-*"]
  }

  filter {
    name   = "root-device-type"
    values = ["ebs"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

module "ec2-instance" {
  source                      = "terraform-aws-modules/ec2-instance/aws"
  version                     = "~> 6.0"
  name                        = "test-webserver-instance"
  ami                         = data.aws_ami.web.id
  associate_public_ip_address = true
  disable_api_termination     = false
  instance_type               = "t3.small"
  key_name                    = "id_citizix"
  monitoring                  = true
  subnet_id                   = "subnet-xxxxxx"

  # The module creates a security group of its own by default; we supply ours.
  create_security_group = false

  vpc_security_group_ids = [
    aws_security_group.ec2-instance-sg.id
  ]

  root_block_device = {
    size                  = 30
    type                  = "gp3"
    delete_on_termination = true
  }
}

resource "aws_security_group" "ec2-instance-sg" {
  name        = "test-webserver-instance-sg"
  description = "Test webserver instance SG "
  vpc_id      = "vpc-xxxxxxx"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = -1
    to_port     = -1
    protocol    = "icmp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

output "public-ip" {
  value = module.ec2-instance.public_ip
}

This example pins the community ec2-instance module to ~> 6.0. If you are following an older version of this guide that used ~> 4.0, note that the module’s root_block_device changed from a list of blocks (volume_size, volume_type) to a single object (size, type) — copying the old snippet into a v6 module will fail to validate.

The next section is to create the resources using terraform. Initialize terraform using this command:

terraform init

Show an execution plan.

terraform plan

Finally apply the changes. You will be shown the execution plan then prompted to confirm the changes by typing yes.

terraform apply

The new instance will be created and its public IP will be shown as part of the outputs. You can also see it in AWS console.

To confirm that out provisioner is working, visit http://server_ip/hello.html.

Once you are done with the test, you should delete the resources to avoid incurring costs. To destroy your test infrastructure, run this command:

terraform destroy

Conclusion

In this guide we learnt how to use Packer to build an AWS AMI with an HCL2 template and a shell provisioner, then verified the resulting image by launching it with Terraform.

If you are maintaining older Packer configs, the two upgrades worth doing first are converting JSON templates to HCL2 (packer hcl2_upgrade) and adding a required_plugins block so packer init can fetch the builders that no longer ship inside the Packer binary.

comments powered by Disqus
Citizix Ltd
Built with Hugo
Theme Stack designed by Jimmy