If you've run AWX for any length of time, you've probably hit the same wall: a playbook needs a Python library, a system package, or a newer collection that isn't in the default image, and suddenly you're stuck. The old answer was a custom virtualenv wedged onto the AWX server. The modern answer is an execution environment — a container image, built specifically for your automation, that AWX pulls and runs jobs inside.
This post walks through what execution environments are, why they replaced virtualenvs, and how to build, test, and register your own.
What an execution environment actually is
An execution environment (EE) is a container image containing everything a job needs to run: ansible-core, Python, any Python libraries your modules depend on, system packages, and Ansible collections. Instead of installing dependencies on the AWX host, you build the image once, push it to a registry, and tell AWX to run jobs inside it. Each job template can point to a different EE, so one AWX instance can happily run playbooks with conflicting dependency requirements — something that was genuinely painful with virtualenvs, since packages had to be installed per node and kept in sync by hand.
The tool that builds these images is ansible-builder. It doesn't build containers directly — it generates a build context (a Containerfile or Dockerfile plus supporting files) from a definition you write, then hands that off to Podman or Docker to actually build the image.
Prerequisites
- ansible-builder installed (
pip install ansible-builder) - Podman or Docker — ansible-builder defaults to Podman but you can pass
--container-runtime=docker - A base image to build from. Red Hat publishes minimal base images for this purpose, but any RPM-based image with
dnformicrodnfworks, since ansible-builder relies on those package managers under the hood
A quick note on base images: smaller is generally better, since it keeps your final image lean, but check what's already installed before you start layering things on. Some base images ship with Python pre-installed; others don't, and you'll waste build time (and image size) reinstalling something that's already there.
The execution-environment.yml file
Everything starts with a definition file — by default execution-environment.yml in your build directory. This is where you declare what goes into the image. A minimal one looks like this:
--- version: 3 dependencies: galaxy: requirements.yml
And the referenced requirements.yml:
--- collections: - name: awx.awx
That's genuinely enough to get a working image. Running:
ansible-builder build
will read execution-environment.yml, generate a build context (Containerfile plus dependency files) in a context/ directory, and invoke Podman to produce the image.
A more realistic definition
Most real-world EEs need more than one collection. Here's a fuller example for a team automating networking and cloud tasks with a couple of Python dependencies:
---
version: 3
images:
base_image:
name: registry.redhat.io/ansible-automation-platform-24/ee-minimal-rhel8:latest
dependencies:
galaxy: requirements.yml
python: requirements.txt
system: bindep.txt
additional_build_steps:
prepend_base:
- RUN whoami
append_final:
- RUN echo "Custom EE build complete"The three dependency files map to the three kinds of things an EE can bundle:
- requirements.yml — Ansible collections (e.g.,
community.general,cisco.ios,amazon.aws) - requirements.txt — Python packages, pinned the way you'd pin them for any production dependency file
- bindep.txt — system-level packages installed via the OS package manager, listed in the bindep format (e.g.,
gcc [platform:rpm]for packages that need compiling)
The additional_build_steps section lets you inject raw build instructions before or after the main install steps — useful for one-off fixes, custom certs, or debugging output during the build.
Building and inspecting the image
Run the build with verbose output so you can see exactly what's happening at each layer:
ansible-builder build -v3 -t my-org/network-ee:1.0
If something fails, it's almost always in one of three places: a missing system dependency needed to compile a Python package, a collection that isn't available in the configured Galaxy source, or a base image that doesn't have the package manager ansible-builder expects. The context/ directory ansible-builder leaves behind is your friend here — you can inspect the generated Containerfile directly, or rebuild manually with podman build context/ to iterate faster without regenerating the context each time.
Once it builds, sanity-check the image before pushing it anywhere:
podman run --rm my-org/network-ee:1.0 ansible --version podman run --rm my-org/network-ee:1.0 ansible-galaxy collection list
Better yet, test it with ansible-navigator or ansible-runner against a real playbook before it ever touches AWX. Catching a missing dependency locally takes two minutes; catching it after a job template fails in AWX takes a support ticket.
Pushing and registering with AWX
Tag and push to whatever registry your AWX instance can reach:
podman tag my-org/network-ee:1.0 quay.io/my-org/network-ee:1.0 podman push quay.io/my-org/network-ee:1.0
Then, in AWX, register it under Administration → Execution Environments: give it a name, point the image field at the full registry path, and set credentials if it's a private registry. From there it's selectable on any job template, right alongside the default AWX EE.
Worth calling out: AWX itself ships with a default EE (awx-ee), built from the same ansible-builder workflow, and its definition is public on GitHub if you want a reference for how a production-grade EE is structured.
A few lessons that save time
Pin everything. Unpinned collections and Python packages mean your "reproducible" image quietly changes behavior on every rebuild. Reproducibility is the entire point of EEs — don't undermine it in requirements.txt.
Keep EEs scoped, not universal. It's tempting to build one giant EE with every collection your org might ever need. Resist it. A bloated image is slower to pull, slower to build, and harder to reason about when something breaks. Scope EEs to a team, platform, or job type instead.
Version your image tags deliberately. latest is fine for local iteration, but job templates in production should point at an explicit version tag so a rebuild elsewhere doesn't silently change what a scheduled job runs.
Keep the build context around. ansible-builder preserves the generated context directory specifically so you can rebuild it later with plain docker build or podman build, without needing ansible-builder installed on the machine doing the build — handy for CI pipelines that just need a container build step.