Running AWX on Docker Compose: A Stand-Alone Alternative to Kubernetes
AWX's official deployment assumes Kubernetes — but what if your org isn't ready for K8s? This guide walks through building a custom AWX Docker image, crafting the Docker Compose configuration, and running 14 job templates with 7 schedules on a single host, complete with ansible-vault credential injection and the AWX-as-code approach.
TL;DR: AWX officially requires Kubernetes, but you can run it on Docker Compose with a custom image that bundles PostgreSQL, Redis, and AWX into a standalone stack. This post walks through the Dockerfile, the compose file, the bootstrap scripts, and the AWX-as-code playbook that configures everything. Full configurations included — everything here is MIT licensed on GitHub.
AWX is Red Hat's web-based UI for Ansible — job scheduling, RBAC, inventory management, and a REST API. It's essentially Ansible Tower as open source. The problem? Its official deployment is designed for Kubernetes.
But not every team has a K8s cluster. Not every workload justifies one. Adding a Kubernetes cluster solely to run a patch management tool is a hard sell when the rest of your infrastructure runs on Docker Compose.
This post documents how I made AWX work on Docker Compose for PatchOps — a fleet-isolated Linux patch management system. It covers the custom Docker image, the bootstrap scripts, credential injection, and the AWX-as-code approach that declares the entire configuration in a single playbook.
If you're running AWX on Kubernetes already, this won't be useful. If you're stuck on Compose and need AWX, read on.
The architecture
AWX has four components:
- PostgreSQL — the application database (separate from any other Postgres you might run)
- Redis — task queue and caching layer
- Web container — Django application serving the UI and REST API via uwsgi + daphne (websockets)
- Task container — background worker that executes Ansible playbooks via receptor and dispatcher
In a K8s deployment, these would be pods with services, ingresses, and persistent volumes. On Docker Compose, they're services with health checks, resource limits, and named volumes.
The key insight: both the web and task containers run the same Docker image with different entrypoints and volume mounts. The image bundles everything AWX needs into a single layer — Django, receptor, nginx, and the Python venv — so the only difference between web and task is which bootstrap script runs at startup.
Building the custom image
The official quay.io/ansible/awx image is built for K8s — it expects a cluster environment. To run it on Compose, I needed to make several changes.
The image starts from the official AWX base and layers on fixes:
ARG AWX_VERSION=24.6.1
FROM quay.io/ansible/receptor:v1.6.5 AS receptor
FROM quay.io/ansible/awx:${AWX_VERSION}
USER root
# Copy receptor binary (needed for standalone mode)
COPY --from=receptor /usr/bin/receptor /usr/local/bin/receptor
# Install docker-ce-cli for EE container execution via host Docker socket
RUN dnf install -y dnf-plugins-core --disablerepo="copr:*" && \
dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && \
dnf install -y docker-ce-cli --disablerepo="copr:*" && \
dnf clean allThe process isolation fix
Here's where it gets interesting. AWX 24.x has podman hardcoded as the execution engine for job containers. A function in jobs.py literally passes "podman" as a string literal. When you set PROCESS_ISOLATION_EXECUTABLE = 'docker' in settings.py, the setting is ignored — the hardcoded string wins.
The fix is a sed substitution that makes the function read from settings.PROCESS_ISOLATION_EXECUTABLE instead:
RUN sed -i 's/"process_isolation_executable": "podman", # need to provide/
"process_isolation_executable": settings.PROCESS_ISOLATION_EXECUTABLE,/' \
/var/lib/awx/venv/awx/lib/python3.11/site-packages/awx/main/tasks/jobs.pyWithout this, every job attempt fails with "podman not found" because the container doesn't have Podman — it uses the Docker socket.
Receptor configuration for standalone mode
In K8s, AWX uses receptor in mesh mode where pods discover each other. On a single host, receptor runs in local-only mode:
RUN cat > /etc/receptor/receptor.conf << 'RECEPTORCONF'
---
- node:
id: awx
- local-only:
local: true
- control-service:
service: control
filename: /var/run/receptor/receptor.sock
- work-command:
worktype: local
command: /var/lib/awx/venv/awx/bin/ansible-runner
params: worker
allowruntimeparams: true
RECEPTORCONFBuilding the image
The build.sh script handles multi-arch builds (amd64 + arm64) with Docker buildx:
# Build for the current architecture
./awx/build.sh
# Build and push multi-arch to Docker Hub
./awx/build.sh --push
# Override AWX version
./awx/build.sh --version 25.0.0The image is published as 2ssk/awx-standalone and referenced in the Compose file.
The Docker Compose configuration
Here's the full compose setup with health checks, resource limits, and proper service dependencies:
name: patchops-awx
x-awx-env: &awx-env
SECRET_KEY: ${AWX_SECRET_KEY}
DATABASE_HOST: postgres
DATABASE_NAME: awx
DATABASE_USER: awx
DATABASE_PASSWORD: ${AWX_DB_PASSWORD:-awxpass}
DATABASE_PORT: "5432"
REDIS_HOST: redis
REDIS_PORT: "6379"
AWX_ADMIN_USER: ${AWX_ADMIN_USER:-admin}
AWX_ADMIN_PASSWORD: ${AWX_ADMIN_PASSWORD}
AWX_ADMIN_EMAIL: ${AWX_ADMIN_EMAIL:-admin@example.com}
AWX_VAULT_PASSWORD: ${AWX_VAULT_PASSWORD}
AWX_ISOLATION_SHARED_PATH: /var/lib/awx
x-awx-volumes: &awx-volumes
- awx_projects:/var/lib/awx/projects
# Mount the playbook repo (read-only)
- ..:/var/lib/awx/projects/patchops:ro
- ${SSH_KEY_PATH}:/var/lib/awx/patchops-ssh-key:ro
- ./settings.py:/etc/tower/settings.py:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- awx_receptor:/var/run/receptor
- ./launch_web.sh:/usr/bin/launch_web.sh
x-task-volumes: &task-volumes
- awx_projects:/var/lib/awx/projects
- ..:/var/lib/awx/projects/patchops:ro
- ./settings.py:/etc/tower/settings.py:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- awx_receptor:/var/run/receptor
- /var/run/docker.sock:/var/run/docker.sock
- ./launch_task.sh:/usr/bin/launch_task.sh
- ./receptor.conf:/etc/receptor/receptor.conf:ro
- /tmp/awx-runner:/tmp/awx-runner
services:
postgres:
image: postgres:17
container_name: patchops-awx-postgres
environment:
POSTGRES_USER: awx
POSTGRES_PASSWORD: ${AWX_DB_PASSWORD:-awxpass}
POSTGRES_DB: awx
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U awx"]
interval: 10s
timeout: 5s
retries: 10
deploy:
resources:
limits:
cpus: "0.5"
memory: 768M
redis:
image: redis:7-alpine
container_name: patchops-awx-redis
command: redis-server --save "" --appendonly no --maxmemory 256mb --maxmemory-policy allkeys-lru
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: "0.25"
memory: 256M
awx_web:
image: 2ssk/awx-standalone:${AWX_VERSION:-latest}
container_name: patchops-awx-web
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
hostname: awxweb
ports:
- "${AWX_PORT:-8080}:8052"
environment: *awx-env
volumes: *awx-volumes
command: /usr/bin/launch_web.sh
user: root
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8052/api/v2/ping/ -o /dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
deploy:
resources:
limits:
cpus: "1"
memory: 1.5G
awx_task:
image: 2ssk/awx-standalone:${AWX_VERSION:-latest}
container_name: patchops-awx-task
depends_on:
awx_web:
condition: service_healthy
hostname: awx
environment: *awx-env
volumes: *task-volumes
command: /usr/bin/launch_task.sh
user: root
privileged: true
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "supervisorctl -c /etc/supervisord_task.conf status | grep -c RUNNING || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
deploy:
resources:
limits:
cpus: "1.5"
memory: 3G
volumes:
postgres_data:
awx_projects:
awx_receptor:Key design decisions:
- Separate volume for PostgreSQL and Redis — data persists across container restarts
- Resource limits — AWX is memory-hungry; the task container gets 3GB max, web gets 1.5GB
- Health checks — all services have them;
awx_taskwaits forawx_webto be healthy before starting - Docker socket mount — the task container binds
/var/run/docker.sockto launch execution environment containers on the host - Project name — explicit
name: patchops-awxprevents container name collisions with other Compose stacks
Bootstrap scripts
Web launcher
The web container's bootstrap script (launch_web.sh) runs database migrations, creates the admin user, sets up the initial AWX objects (organization, credentials, execution environment, project), and then hands off to AWX's own entrypoint:
#!/bin/bash
set -e
# Run database migrations (idempotent)
awx-manage migrate --noinput
# Bootstrap: admin user + core objects via Django shell
awx-manage shell << 'PYEOF'
import os
from django.contrib.auth.models import User
from awx.main.models import (
Organization, Credential, CredentialType,
Project, ExecutionEnvironment,
)
# Clear AWX_ISOLATION_SHOW_PATHS (Docker rejects Podman's :O overlay mode)
from awx.conf.models import Setting
obj, _ = Setting.objects.update_or_create(
key='AWX_ISOLATION_SHOW_PATHS', defaults={'value': []}
)
# Set --network host for EE containers (reach patchops fleet on localhost)
obj, _ = Setting.objects.update_or_create(
key='DEFAULT_CONTAINER_RUN_OPTIONS',
defaults={'value': ['--network', 'host']}
)
# Create admin user
admin, _ = User.objects.get_or_create(username=os.environ['AWX_ADMIN_USER'])
admin.set_password(os.environ['AWX_ADMIN_PASSWORD'])
admin.is_superuser = True
admin.save()
# Create organization, credentials, EE, project...
# (Full script: https://github.com/2SSK/patchops/blob/main/awx/launch_web.sh)
PYEOF
# Hand off to AWX's official web entrypoint
exec /usr/bin/launch_awx_web.shTask launcher
The task container's bootstrap (launch_task.sh) is more involved. It must start receptor before the dispatcher, patch ansible-runner to handle disabled process isolation, and register the instance without K8s assumptions:
#!/bin/bash
set -e
# Start receptor in the background before supervisord
receptor --config /etc/receptor/receptor.conf > /var/log/receptor.log 2>&1 &
RECEPTOR_PID=$!
echo "[task] Waiting for receptor socket..."
for i in $(seq 1 30); do
[ -S /var/run/receptor/receptor.sock ] && echo "[task] Ready" && break
if ! kill -0 $RECEPTOR_PID 2>/dev/null; then
echo "[task] Receptor died! Log:"; cat /var/log/receptor.log; exit 1
fi
sleep 1
done
# Patch ansible-runner to handle PROCESS_ISOLATION_EXECUTABLE=None
python3 -c "
import os
fpath = '/var/lib/awx/venv/awx/lib/python3.11/site-packages/ansible_runner/utils/__init__.py'
content = open(fpath).read()
if 'if not isolation_executable:' not in content:
content = content.replace(
'def check_isolation_executable_installed(isolation_executable: str) -> bool:',
'def check_isolation_executable_installed(isolation_executable: str) -> bool:\n if not isolation_executable: return False'
)
open(fpath, 'w').write(content)
print('Patched ansible-runner')
"
# Register instance with explicit node_type (prevents K8s mode errors)
awx-manage provision_instance --hostname=awx --node_type=hybrid || true
# Create instance groups (required - scheduler crashes without them)
awx-manage register_queue --queuename controlplane --hostnames awx || true
awx-manage register_queue --queuename default --hostnames awx || true
# Start supervisor (manages all AWX task services)
exec supervisord -c /etc/supervisord_task.confThe receptor startup sequence is critical. If the dispatcher starts before the receptor socket exists, every job dispatch fails with "socket does not exist". The script waits up to 30 seconds for the socket to appear, and fails with a clear error if receptor dies.
Settings.py for stand-alone mode
AWX settings are split between settings.py (static, baked into the container) and the awx.conf table in PostgreSQL (dynamic, editable via the UI or API). For standalone mode, the critical settings are:
# Use Docker for EE container isolation (not Podman)
PROCESS_ISOLATION_EXECUTABLE = 'docker'
# EE containers need host network to reach localhost-mapped fleet
DEFAULT_CONTAINER_RUN_OPTIONS = ['--network', 'host']
# Shared path for job private data (host-accessible for Docker bind mount)
AWX_ISOLATION_BASE_PATH = '/tmp/awx-runner'
# AWX reaches awxweb over plain HTTP inside Docker
BROADCAST_WEBSOCKET_PORT = 8052
BROADCAST_WEBSOCKET_PROTOCOL = 'http'
ALLOWED_HOSTS = ['*']
USE_X_FORWARDED_HOST = True
USE_X_FORWARDED_PORT = TrueThe dynamic settings (AWX_ISOLATION_SHOW_PATHS, DEFAULT_CONTAINER_RUN_OPTIONS) are set by launch_web.sh on every boot.
AWX-as-code: configuring jobs and schedules
All AWX objects — inventories, hosts, groups, job templates, and schedules — are declared in a single Ansible playbook using the awx.awx collection:
# Prerequisites
pip install awxkit
ansible-galaxy collection install awx.awx
# Configure AWX (point at your local instance)
CONTROLLER_HOST=http://localhost:8080 \
CONTROLLER_USERNAME=admin \
CONTROLLER_PASSWORD=<password> \
ansible-playbook awx/configure.ymlThe playbook creates:
- 1 inventory with 3 environment groups (dev, staging, prod) and 7 hosts
- 4 setup job templates (init-aptly, setup-aptly, setup-govdb, configure-clients × 3 envs)
- 7 pipeline job templates (snapshot-create, patch-scan, patch-dev, promote-staging, apply-staging, promote-prod, apply-prod)
- 1 rollback template with a survey for environment and snapshot name
- 7 cron schedules matching the daily/weekly promotion cadence
Example: creating a job template:
- name: Create pipeline job templates
awx.awx.job_template:
name: "{{ item.name }}"
organization: PatchOps
job_type: run
inventory: patchops
project: patchops
playbook: "{{ item.playbook }}"
execution_environment: patchops-ee
credentials: [patchops-machine-ssh, patchops-vault]
become_enabled: true
limit: "{{ item.limit | default(omit) }}"
state: present
loop:
- { name: "aptly | snapshot-create", playbook: playbooks/snapshot-create.yml, limit: aptly }
- { name: "patchops | patch-scan", playbook: playbooks/patch-scan.yml, limit: ubuntu }
- { name: "patchops | patch-dev", playbook: playbooks/patch-security.yml, limit: dev }
- { name: "aptly | promote-staging", playbook: playbooks/promote-staging.yml, limit: aptly }
- { name: "patchops | apply-staging", playbook: playbooks/patch-security.yml, limit: staging }
- { name: "aptly | promote-prod", playbook: playbooks/promote-prod.yml, limit: aptly }
- { name: "patchops | apply-prod", playbook: playbooks/patch-security.yml, limit: prod }And the corresponding schedules:
- name: Schedule daily aptly snapshot-create
awx.awx.schedule:
name: "aptly | snapshot-create — daily 01:00 UTC"
unified_job_template: "aptly | snapshot-create"
rrule: "DTSTART:20260101T010000Z RRULE:FREQ=DAILY;INTERVAL=1"
enabled: true
state: presentThis is AWX-as-code: the entire AWX configuration can be destroyed and recreated with one command.
Credential injection
Secrets never appear in plaintext environment variables. The Stack:
ansible-vaultencryptsgroup_vars/all/vault.ymlin the repository- The vault password is stored only in a local
.vault_passfile (gitignored) or injected asAWX_VAULT_PASSWORD - AWX decrypts the vault at runtime using the
patchops-vaultcredential - SSH keys are mounted as a read-only file from the host
# AWX decrypts the vault automatically via the credential
# No secrets in the compose file, no secrets in environment variablesThis means the entire repository — including the AWX configuration playbook — can be committed to Git without exposing passwords.
Security considerations
Running AWX on Docker Compose involves tradeoffs:
- Docker socket access — the task container mounts
/var/run/docker.sockto launch EE containers. This means it has effective root access to the Docker daemon. If an attacker compromises the task container, they control all containers on the host. - Privileged mode — the task container runs with
privileged: truefor receptor and Docker operations. - Single host — there's no high availability. If the host goes down, AWX and all scheduled jobs stop.
For a fleet of fewer than 50 hosts, these tradeoffs are acceptable. The alternative — adding a multi-node Kubernetes cluster — would increase complexity more than it reduces risk at this scale.
If your fleet outgrows this setup, graduate to the official AWX K8s deployment. The playbooks, credentials, and inventory all transfer — only the Compute layer changes.
Running it
# Clone the repo
git clone https://github.com/2SSK/patchops
cd patchops
# 1. Build the custom AWX image (first time only)
./awx/build.sh
# 2. Start the AWX stack
cd awx
cp .env.example .env # Edit your secrets
docker compose up -d
# 3. Configure jobs and schedules (once AWX is healthy)
pip install awxkit
ansible-galaxy collection install awx.awx
CONTROLLER_HOST=http://localhost:8080 \
CONTROLLER_PASSWORD=<your-password> \
ansible-playbook configure.yml
# 4. Open https://localhost:8080The first startup takes about 2 minutes — PostgreSQL needs to initialize, migrations run, and receptor establishes its socket. After that, the web UI is accessible, jobs run on schedule, and you can monitor everything from the AWX dashboard.
This is part of the PatchOps series. For the full story — why I built a fleet-isolated patch management system and how the rest of the stack works — see Controlled Linux Patch Management: From Supply Chain Risk to Fleet-Isolated Package Delivery.
The full source code is on GitHub — MIT licensed, Docker-based lab included. If you're running AWX on Docker Compose or have questions about the setup, I'd love to hear about it.