Packer and Ansible are a powerful duo for automating image creation, but the real magic happens when you realize that the image you’re building is essentially a highly customized, pre-configured server waiting to be launched.
Let’s see this in action. Imagine we’re building a base Ubuntu 22.04 image for web servers. Our Packer template might look something like this:
{
"builders": [
{
"type": "amazon-ebs",
"region": "us-east-1",
"source_ami": "ami-0c55b159cbfafe1f0", // Ubuntu 22.04 LTS (Jammy Jellyfish)
"instance_type": "t2.micro",
"ssh_username": "ubuntu",
"ami_name": "my-webserver-base-{{timestamp}}"
}
],
"provisioners": [
{
"type": "ansible",
"playbook_file": "./provisioning/site.yml",
"extra_arguments": [
"--tags", "base_setup"
]
}
]
}
And our Ansible playbook (provisioning/site.yml) might have a role like this:
---
- name: Base server setup
hosts: all
become: yes
tasks:
- name: Update apt cache
apt:
update_cache: yes
- name: Install common packages
apt:
name:
- nginx
- ufw
- git
- curl
state: present
When you run packer build your-template.json, Packer spins up a temporary EC2 instance, connects to it via SSH, and then executes the specified Ansible playbook. It installs Nginx, configures the firewall, and adds other essentials. Once the playbook finishes, Packer captures a snapshot of this configured instance as a new AMI, and then tears down the temporary instance. You’re left with a ready-to-go AMI that already has your web server stack installed.
The core problem Packer + Ansible solves is the "snowflake server" anti-pattern. Without automation, you’d manually set up a server, install software, configure it, and then maybe try to document it. This process is error-prone, slow, and impossible to scale. When you need another identical server, you repeat the whole manual process. Packer and Ansible turn this into a repeatable, auditable, and fast process. You define your desired server state in code (Packer template and Ansible playbook), and Packer builds an image representing that state.
Internally, Packer acts as an orchestrator. It’s responsible for the lifecycle of the temporary machine: launching it, connecting to it, running your provisioners (like Ansible), and then capturing the final state as an artifact (in this case, an AMI). It doesn’t know Ansible; it just knows how to execute it and pipe its output. Ansible, on the other hand, is the workhorse that performs the actual configuration on the temporary machine. It uses SSH to execute commands, manage packages, and modify configuration files, all based on the declarative rules you provide in your playbooks.
The extra_arguments in the Packer provisioner block are crucial for fine-grained control. Here, --tags base_setup tells Ansible to only run tasks tagged with base_setup in our playbook. This allows you to have a single playbook that can be used for different stages of image building or even for post-launch configuration. You could have tags like base_setup, app_deploy, monitoring_agent, etc., and selectively apply them during the Packer build process.
One common misconception is that Packer is the configuration management tool. It’s not. Packer is the builder; Ansible (or Shell, Chef, Puppet) is the config manager. Packer’s job is to get a machine running, give your config manager a place to work, and then capture the result. If you try to put complex logic directly into Packer’s JSON, you’re fighting its design. The beauty is in the separation of concerns: Packer defines how to build the image, and Ansible defines what goes into the image.
The next step after mastering base image creation is often integrating application code or more complex application stacks directly into the image, leading to immutable infrastructure patterns.