cd ..
linux

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.

10 min read

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 all

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

Without 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
RECEPTORCONF

Building 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.0

The 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_task waits for awx_web to be healthy before starting
  • Docker socket mount — the task container binds /var/run/docker.sock to launch execution environment containers on the host
  • Project name — explicit name: patchops-awx prevents 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.sh

Task 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.conf

The 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 = True

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

The 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: present

This 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:

  1. ansible-vault encrypts group_vars/all/vault.yml in the repository
  2. The vault password is stored only in a local .vault_pass file (gitignored) or injected as AWX_VAULT_PASSWORD
  3. AWX decrypts the vault at runtime using the patchops-vault credential
  4. 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 variables

This 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.sock to 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: true for 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:8080

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

More to Read