What is HashiCorp Packer?
When you launch an EC2 instance, you typically choose a base AMI and then run a user-data script or a configuration management tool to install software and configure the server. This works, but it has a problem: every time you launch a new instance, you are running configuration code - code that can fail, drift from what you expect, or take minutes to complete before the instance is ready to serve traffic.
Packer solves this by doing all that configuration work once at image-build time, producing a fully baked AMI. New instances boot from that image in seconds, already configured and ready to go, with no post-provisioning steps.
How Packer Works
Packer follows a simple three-phase process:
- Launch: Packer starts a temporary build instance using a source image (the base AMI, for example).
- Provision: Packer runs provisioners - shell scripts, Ansible playbooks, or Chef recipes - on the temporary instance to install and configure software.
- Create image: Packer captures the configured instance as a new AMI (or image for the target platform), then terminates the temporary instance.
The resulting image is immutable. Every instance launched from it will have exactly the same software, configuration, and security hardening - forever, regardless of when or where it is launched.
Packer vs Docker
The natural question is: if we have Docker, why do we need Packer?
Docker builds container images that run on a container runtime. Packer builds full VM images that boot as complete virtual machines with their own OS kernel. These serve different workloads.
Use Packer + Terraform for EC2-based workloads: Auto Scaling groups, self-managed Kubernetes worker nodes, workloads that cannot be containerised easily (legacy applications, GPU workloads, applications with OS-level dependencies).
Use Docker for containerised applications that run on Kubernetes or ECS. Many organisations use both: Docker for their microservices, Packer to bake the EC2 AMIs that serve as the base nodes for their EKS cluster or Terraform-managed instances.
A Real Packer Template for an Ubuntu AMI
# packer.pkr.hcl -- build a hardened Ubuntu 24.04 AMI for AWS
packer {
required_plugins {
amazon = {
version = ">= 1.3.0"
source = "github.com/hashicorp/amazon"
}
}
}
variable "aws_region" {
default = "eu-west-2"
}
variable "app_version" {
default = "1.0.0"
}
source "amazon-ebs" "ubuntu" {
ami_name = "cloudpros-app-${var.app_version}-{{timestamp}}"
instance_type = "t3.small"
region = var.aws_region
source_ami_filter {
filters = {
name = "ubuntu/images/hvm-ssd/ubuntu-noble-24.04-amd64-server-*"
root-device-type = "ebs"
virtualization-type = "hvm"
}
most_recent = true
owners = ["099720109477"] # Canonical's AWS account
}
ssh_username = "ubuntu"
tags = {
Name = "cloudpros-app-${var.app_version}"
BuildTime = "{{timestamp}}"
AppVersion = var.app_version
ManagedBy = "Packer"
}
}
build {
name = "cloudpros-app-ami"
sources = ["source.amazon-ebs.ubuntu"]
# System hardening
provisioner "shell" {
inline = [
"sudo apt-get update -y",
"sudo apt-get upgrade -y",
"sudo apt-get install -y unattended-upgrades fail2ban",
"sudo systemctl enable fail2ban",
]
}
# Install Node.js runtime
provisioner "shell" {
inline = [
"curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -",
"sudo apt-get install -y nodejs",
"node --version",
]
}
# Copy application code
provisioner "file" {
source = "dist/"
destination = "/opt/app/"
}
# Configure and enable systemd service
provisioner "shell" {
inline = [
"sudo useradd -r -s /sbin/nologin appuser",
"sudo chown -R appuser:appuser /opt/app",
"sudo systemctl daemon-reload",
"sudo systemctl enable app.service",
]
}
# Clean up before creating the image
provisioner "shell" {
inline = [
"sudo apt-get autoremove -y",
"sudo apt-get clean",
"sudo rm -rf /tmp/* /var/log/*.log",
"sudo truncate -s 0 /etc/machine-id",
]
}
post-processor "manifest" {
output = "manifest.json"
strip_path = true
}
}
The post-processor "manifest" block writes the resulting AMI ID to manifest.json. Your CI pipeline can then read this file and pass the AMI ID to Terraform as a variable.
The Packer + Terraform Workflow
This is the canonical immutable infrastructure pattern:
# Step 1: Build the AMI with Packer (runs in CI)
packer build -var "app_version=$GIT_TAG" packer.pkr.hcl
# Step 2: Extract the AMI ID from the manifest
AMI_ID=$(jq -r '.builds[0].artifact_id' manifest.json | cut -d: -f2)
# Step 3: Pass the AMI ID to Terraform
terraform apply -var "app_ami_id=$AMI_ID"
In Terraform, your launch template references the variable:
resource "aws_launch_template" "app" {
name_prefix = "cloudpros-app-"
image_id = var.app_ami_id
instance_type = "t3.medium"
}
Updating the application now means: build a new AMI with Packer, get the new AMI ID, apply Terraform to roll out the new image to your Auto Scaling group. Instances are replaced, not mutated. Configuration drift is impossible.
