All guides

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.

7 min readUpdated November 8, 2026

  • Packer
  • Ansible
  • Change Management
  • Configuration Management
  • AWS

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.

change-management Configuration and change management

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 ansible provisioner runs it from your machine over SSH)

On macOS:

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

On 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-1

If 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.j2

The 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 = false lets 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: reloaded

The 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.target

And 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 echo in 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 .
  • init installs the Amazon and Ansible plugins.
  • fmt and validate catch formatting and syntax errors before you spend money on an instance.
  • build starts 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-0123456789abcdef0

Launch 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 text

Open http://<public-ip> in your browser and you'll see It works! 🥂.

With the console

  1. Open the EC2 console in the region you built in.
  2. Go to Images → AMIs, select your express-app-… image, and choose Launch instance from AMI.
  3. Pick an instance type (t3.micro is fine) and a key pair.
  4. Under Network settings, allow HTTP from anywhere and SSH from My IP.
  5. 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):

  1. Change the playbook or template, and open a pull request so the change is reviewed.
  2. Run packer build . to bake a new AMI.
  3. 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 → Snapshots

The original code for this guide is on GitHub.

Marcus Chukwuoma

DevOps / Software Engineer. I write practical guides on infrastructure, CI/CD and testing.