Configuration and change management with Packer and Ansible
Build an AWS machine image with Packer (HCL2), configure it with an Ansible playbook, and launch an EC2 instance that serves an Express app behind NGINX.
In this guide we'll automate the setup of a server end to end. Packer builds an Amazon Machine Image (AMI), Ansible configures it (Node.js, an Express app, NGINX as a reverse proxy), and we launch an EC2 instance from the result. Every server you launch from that image is identical, and changing the setup means changing code, not clicking around a console.
Configuration vs. change management
They're related, but not the same:
- Configuration management keeps a known, versioned record of how your systems are set up: packages and their versions, config files, users, network addresses. If a server dies, you can rebuild it exactly.
- Change management is how changes to that setup are proposed, reviewed, applied and rolled back.
Keeping both in code (here, a Packer template and an Ansible playbook in git) gives you:
- Reliability. The setup is written down, not in someone's head. Failures are easier to recover from because rebuilding is a command.
- A history. Git shows what changed, when and why, which makes the next decision easier.
- Lower cost and risk. Fewer one-off servers, less drift, and reviewable changes.
The tools
- Packer builds machine images from a template. It starts a temporary instance, runs your provisioners on it, saves the disk as an image, and cleans up.
- Ansible describes the desired state of a machine in YAML playbooks and applies it over SSH. Its modules are idempotent: running a playbook twice gives the same result.
Baking the configuration into an image (instead of configuring servers after they boot) is often called immutable infrastructure: to change a server, you build a new image and replace it.
What you need
- An AWS account and credentials that can create EC2 instances, AMIs, key pairs and security groups
- Packer 1.10 or newer
- Ansible on your machine (Packer's
ansibleprovisioner runs it from your machine over SSH)
On macOS:
brew tap hashicorp/tap
brew install hashicorp/tap/packer
brew install ansibleOn Windows, Packer is available with choco install packer. Ansible's control node doesn't run on
Windows directly; use WSL.
AWS credentials
Packer uses the standard AWS credential chain, so whatever works for the AWS CLI works here: an
SSO profile, ~/.aws/credentials, or environment variables.
aws configure sso # or: aws configure
export AWS_PROFILE=my-profile
export AWS_REGION=eu-west-1If you prefer a .env file, keep it out of git. If you use VS Code,
IGNOREIT adds .env to .gitignore for you and can keep a
.env.example in sync.
Project layout
packer-ansible/
├── packer/
│ └── express-app.pkr.hcl
└── ansible/
├── playbook.yml
└── templates/
├── express-app.service.j2
└── nginx-site.conf.j2The Packer template
Packer templates are written in HCL2 (the old JSON format is deprecated). Plugins are separate
from Packer, and the required_plugins block lets packer init install them.
# packer/express-app.pkr.hcl
packer {
required_plugins {
amazon = {
source = "github.com/hashicorp/amazon"
version = ">= 1.3.0"
}
ansible = {
source = "github.com/hashicorp/ansible"
version = ">= 1.1.0"
}
}
}
variable "region" {
type = string
default = "eu-west-1"
}
variable "instance_type" {
type = string
default = "t3.micro"
}
locals {
timestamp = formatdate("YYYYMMDD-hhmmss", timestamp())
}
source "amazon-ebs" "ubuntu" {
region = var.region
instance_type = var.instance_type
ssh_username = "ubuntu"
ami_name = "express-app-${local.timestamp}"
# Latest Ubuntu 24.04 LTS image published by Canonical
source_ami_filter {
filters = {
name = "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"
root-device-type = "ebs"
virtualization-type = "hvm"
}
owners = ["099720109477"] # Canonical
most_recent = true
}
tags = {
Name = "express-app"
BuiltBy = "packer"
}
}
build {
sources = ["source.amazon-ebs.ubuntu"]
provisioner "ansible" {
playbook_file = "../ansible/playbook.yml"
user = "ubuntu"
use_proxy = false
}
}A few things to note:
- The AMI name includes a timestamp, because AMI names must be unique. Every build creates a new image instead of failing on the second run.
owners = ["099720109477"]is Canonical's account, so you only ever get official Ubuntu images.use_proxy = falselets Ansible connect straight to the build instance over SSH, which avoids problems with the plugin's local proxy on newer OpenSSH versions.
The Ansible playbook
The playbook installs Node.js 22 from NodeSource, deploys a small Express app (it listens on port 3000), runs it as a systemd service, and puts NGINX in front on port 80.
# ansible/playbook.yml
- name: Configure the Express app server
hosts: all
become: true
vars:
app_name: express-app
app_user: ubuntu
app_dir: /opt/express-app
app_repo: https://github.com/marcdomain/basic-express-app.git
app_port: 3000
node_major: 22
tasks:
- name: Install base packages
ansible.builtin.apt:
# acl lets Ansible run tasks as the app user; python3-debian is needed by deb822_repository
name: [acl, ca-certificates, curl, git, nginx, python3-debian]
state: present
update_cache: true
- name: Add the NodeSource repository and its signing key
ansible.builtin.deb822_repository:
name: nodesource
types: deb
uris: "https://deb.nodesource.com/node_{{ node_major }}.x"
suites: nodistro
components: main
signed_by: https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key
- name: Install Node.js
ansible.builtin.apt:
name: nodejs
state: present
update_cache: true
- name: Clone the app
ansible.builtin.git:
repo: "{{ app_repo }}"
dest: "{{ app_dir }}"
version: master
- name: Give the app user the app folder
ansible.builtin.file:
path: "{{ app_dir }}"
owner: "{{ app_user }}"
group: "{{ app_user }}"
recurse: true
- name: Install production dependencies
ansible.builtin.command: npm install --omit=dev
args:
chdir: "{{ app_dir }}"
become_user: "{{ app_user }}"
changed_when: true
- name: Install the systemd service
ansible.builtin.template:
src: templates/express-app.service.j2
dest: /etc/systemd/system/{{ app_name }}.service
mode: "0644"
notify: Restart app
- name: Start the app on boot
ansible.builtin.systemd_service:
name: "{{ app_name }}"
enabled: true
state: started
daemon_reload: true
- name: Remove the default NGINX site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Reload nginx
- name: Add the reverse proxy site
ansible.builtin.template:
src: templates/nginx-site.conf.j2
dest: /etc/nginx/sites-available/{{ app_name }}
mode: "0644"
notify: Reload nginx
- name: Enable the reverse proxy site
ansible.builtin.file:
src: /etc/nginx/sites-available/{{ app_name }}
dest: /etc/nginx/sites-enabled/{{ app_name }}
state: link
notify: Reload nginx
- name: Start NGINX on boot
ansible.builtin.systemd_service:
name: nginx
enabled: true
state: started
handlers:
- name: Restart app
ansible.builtin.systemd_service:
name: "{{ app_name }}"
state: restarted
daemon_reload: true
- name: Reload nginx
ansible.builtin.systemd_service:
name: nginx
state: reloadedThe systemd unit keeps the app running and restarts it if it crashes:
# ansible/templates/express-app.service.j2
[Unit]
Description={{ app_name }}
After=network.target
[Service]
User={{ app_user }}
WorkingDirectory={{ app_dir }}
Environment=NODE_ENV=production
ExecStart=/usr/bin/node app.js
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.targetAnd the NGINX site forwards port 80 to the app:
# ansible/templates/nginx-site.conf.j2
server {
listen 80 default_server;
server_name _;
location / {
proxy_pass http://127.0.0.1:{{ app_port }};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Why these choices:
- Fully qualified module names (
ansible.builtin.apt) are the current Ansible style and avoid clashes with collections. - Templates instead of
echoin shell tasks make config files reviewable, and Ansible only changes them when their content changes. - Handlers restart or reload a service only when something it depends on actually changed.
- systemd instead of a process manager means one less tool to install: the OS starts the app on boot and restarts it on failure.
Build the image
cd packer
packer init .
packer fmt .
packer validate .
packer build .initinstalls the Amazon and Ansible plugins.fmtandvalidatecatch formatting and syntax errors before you spend money on an instance.buildstarts a temporary EC2 instance, runs the playbook on it, creates the AMI, and terminates the instance.
Override a variable at build time with -var:
packer build -var "region=us-east-1" -var "instance_type=t3.small" .At the end, Packer prints the new AMI ID:
==> Builds finished. The artifacts of successful builds are:
--> amazon-ebs.ubuntu: AMIs were created:
eu-west-1: ami-0123456789abcdef0Launch an instance from the image
With the AWS CLI
Create a security group that allows HTTP from anywhere and SSH only from your IP:
MY_IP=$(curl -s https://checkip.amazonaws.com)
SG_ID=$(aws ec2 create-security-group \
--group-name express-app \
--description "HTTP from anywhere, SSH from my IP" \
--query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$SG_ID" --protocol tcp --port 80 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress --group-id "$SG_ID" --protocol tcp --port 22 --cidr "$MY_IP/32"Then launch an instance from your AMI (replace the AMI ID and key pair name):
INSTANCE_ID=$(aws ec2 run-instances \
--image-id ami-0123456789abcdef0 \
--instance-type t3.micro \
--key-name my-key \
--security-group-ids "$SG_ID" \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=express-app}]' \
--query 'Instances[0].InstanceId' --output text)
aws ec2 wait instance-running --instance-ids "$INSTANCE_ID"
aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output textOpen http://<public-ip> in your browser and you'll see It works! 🥂.
With the console
- Open the EC2 console in the region you built in.
- Go to Images → AMIs, select your
express-app-…image, and choose Launch instance from AMI. - Pick an instance type (
t3.microis fine) and a key pair. - Under Network settings, allow HTTP from anywhere and SSH from My IP.
- Launch, then open the instance's Public IPv4 address in your browser.
Note that the security group opens port 80 (NGINX), not 3000. The app only listens on localhost; NGINX is the only way in.
Making a change
This is where the setup pays off. To change the server (a new Node.js version, an NGINX header, a new app release):
- Change the playbook or template, and open a pull request so the change is reviewed.
- Run
packer build .to bake a new AMI. - Launch new instances from it (or update your launch template / Auto Scaling group), check them, then terminate the old ones.
Rolling back is launching from the previous AMI.
Clean up
AMIs keep a snapshot, which costs money while it exists. When you're done:
aws ec2 terminate-instances --instance-ids "$INSTANCE_ID"
aws ec2 deregister-image --image-id ami-0123456789abcdef0
# then delete the AMI's snapshot under EC2 → SnapshotsThe original code for this guide is on GitHub.
