Technology strategy that keeps your business moving.

Agincourt helps growing companies run reliable systems, from Unix and Linux engineering to databases and disaster recovery — without the jargon or the surprise invoices.

Book a free consultationOur services
30+years of experience
240projects delivered
99.9%uptime managed
24/7monitoring

What we do

Practical, vendor-neutral advice and hands-on delivery across the full technology stack.

Services

Engage us for a single project or as your fractional IT department.

About Agincourt

We're a team of engineers, architects and security specialists who got tired of seeing small and mid-sized businesses oversold and under-supported.

Our approach is simple: understand the business goal first, recommend the smallest solution that works, and stay accountable after launch. Every engagement ends with clear documentation your team actually owns.

Sectors we serve

Our experience spans industries where reliability, security and continuity of service are essential.

Public Sector

IT consulting for federal and municipal government organizations, with hands-on experience of public-sector technology environments, operational requirements and service delivery.

Financial Services

Consulting for banks and financial institutions, where system reliability, security, operational continuity and regulatory requirements come first.

Telecommunications

IT consulting within telecommunications environments, supporting core technology infrastructure, service operations and enterprise IT requirements.

Professional Services and Consulting

Engagements alongside large consulting firms, contributing deep technical expertise and IT services to enterprise client environments and complex technology projects.

Our principles

Plain language

No acronyms without explanations. You'll always know what we're doing and why.

Security by default

Least privilege, MFA and tested backups are built in, not bolted on.

Vendor neutral

We take no kickbacks. Recommendations are based only on your needs.

ABS Lab Projects

Hands-on lab work from our engineers: real builds, the problems we hit, and how we solved them.

Note: The virtual machines described here run in a private lab behind a firewall and cannot be reached from the internet. The labs are for training purposes only.

← All lab projects

Note: The virtual machines described here run in a private lab behind a firewall and cannot be reached from the internet.

AWX Lab Setup: Project Summary

A summary of setting up AWX on VirtualBox and running a first playbook against a test VM.

1. Goal and Environment

  • Goal: run AWX (upstream of Ansible Automation Platform) in a lab and use it to run Ansible playbooks against a test VM
  • Host: Windows laptop running Oracle VirtualBox
  • VMs:
    • absubn01: Ubuntu Server running AWX
    • absubn02: Ubuntu test VM (reached at 192.168.2.9 in the job output)
  • Deployment choice: k3s (single-node lightweight Kubernetes) plus the AWX Operator, chosen as the easiest option for a single VM

2. VM and Network Preparation

  • Sizing for the AWX VM: 4 vCPUs, 8 GB RAM, 40 GB disk, Ubuntu Server 22.04/24.04
  • Windows considerations: Hyper-V/WSL2 can conflict with VirtualBox (check with msinfo32; bcdedit /set hypervisorlaunchtype off if needed)
  • Two-adapter design: NAT for internet access, host-only for a stable address reachable from Windows
  • Static IP: configured in Netplan on the host-only adapter, with no gateway on it; used netplan try to apply safely
  • SSH: installed openssh-server on the VMs; Windows has a built-in client

3. Installing AWX

  • k3s: installed with the official one-line script, then copied /etc/rancher/k3s/k3s.yaml to ~/.kube/config so kubectl works for the normal user
  • AWX Operator: installed with kustomize from the ansible/awx-operator repo (release 2.19.1)
  • AWX instance: created from an awx-demo.yaml custom resource, exposed as a NodePort on 30080
  • Access: http://<absubn01-ip>:30080, user admin, with the password read from the awx-demo-admin-password secret

4. Problems Solved

  • ImagePullBackOff on the operator pod
    • Cause: the kube-rbac-proxy image pointed at the retired gcr.io/kubebuilder registry
    • Fix: redirected it to quay.io/brancz/kube-rbac-proxy in kustomization.yaml
  • Kustomize error about a missing awx-demo.yaml
    • Cause: the file was referenced before it was created
    • Fix: removed the reference first, then created the file and added it back
  • "Permission denied (publickey,password)" on the first connectivity test
    • Cause: a mismatch between the key in AWX and the one installed on absubn02, or permissions on .ssh
    • Fix: compared key fingerprints, corrected authorized_keys and permissions, and tested a manual SSH login from absubn01

5. Preparing the Test VM (absubn02)

  • Service account: dedicated awx user
  • Sudo: passwordless, via a file in /etc/sudoers.d/awx, validated with visudo -cf
  • Authentication: SSH key pair (ed25519), public key in /home/awx/.ssh/authorized_keys, with correct ownership and modes
  • Verification: manual login and sudo whoami from absubn01

6. Running the First Playbook in AWX

The objects were built in dependency order:

  • Credential: Machine type, user awx, private key pasted in, privilege escalation set to sudo
  • Inventory and host: lab inventory containing absubn02 (by IP)
  • Connectivity test: ad hoc ping module, which succeeded after the SSH fix
  • Project: Git repo on GitHub containing test.yml, synced into AWX
  • Job template: ties together inventory, project, playbook, and credential
  • Result: the playbook ran successfully, showing the OS version and confirming sudo returns root

7. Scaling Up: Managing Multiple Hosts

  • Bulk add in the UI: the "Add bulk host" option, if your AWX version has it
  • Inventory file in Git (recommended): an inventory.yml synced through an inventory source of type "Sourced from a Project", with Overwrite and Update on launch; hand-added hosts in that inventory are deleted on sync
  • Groups: used for targeting subsets with the template's Limit field and for group-level variables

8. Suggested Next Steps

  • Playbooks: add real ones (package installs, users, config files), each with its own job template
  • Surveys: prompt for variables at launch
  • Schedules: recurring runs of templates
  • Workflows: chain templates with success and failure branches
  • Notifications and RBAC: Slack or email alerts, plus users and teams with limited permissions
  • Housekeeping: take a VirtualBox snapshot of the working AWX VM
← All lab projects
← All lab projects

Note: The virtual machines described here run in a private lab behind a firewall and cannot be reached from the internet.

Web Server Deployment

AWX Job Summary: Setup Web Server on absubn02 · ABS Lab AWX Playbooks | October 25, 2026

1. Overview

This document records how an Apache web server was set up on the host absubn02 using an AWX job template and a new playbook, 2web.yml. It covers the playbook design, the AWX template configuration, and the result of the first run (job 15), which completed successfully.

Outcome: Apache is installed on absubn02, runs as the apache user and group, and is configured to serve content from /data. The only remaining manual step is uploading index.html.

2. Requirements

  1. Install and configure the Apache web server on absubn02, owned by and running as the apache account and apache group.

  2. Use /data as the content directory, owned by apache:apache.

  3. The main content file is index.html, uploaded manually (not managed by the playbook).

3. AWX Job Template Configuration

Field Value
Name setup web server
Organization Default
Inventory abs lab
Limit absubn02
Project lab-playbooks
Playbook 2web.yml
Credentials absubn02-awx
Execution Environment AWX EE
Privilege Escalation Enabled

The inventory contains more than the one host, so the Limit field is what restricts the run to absubn02. The playbook itself targets hosts: all.

AWX job template details for setup web server

Figure 1. Job template details for setup web server. Illustrative reproduction built from the template inputs, not a live capture.

4. Playbook Creation

4.1 Steps taken

  1. Wrote the playbook 2web.yml to meet the three requirements above.

  2. Committed 2web.yml to a public GitHub repository.

  3. Synced the existing AWX project lab-playbooks so AWX picked up the new playbook.

  4. Created the job template setup web server with the settings in section 3.

  5. Launched the template. The run was job 15.

4.2 What the playbook does

The playbook is written for Debian/Ubuntu hosts (absubn02 runs Ubuntu, with Python 3.12 discovered by Ansible).

  1. Verifies the OS family. It stops with a clear message if the host is not Debian/Ubuntu.

  2. Installs Apache (apache2).

  3. Creates the apache group and user as a system account with no login shell and no home directory.

  4. Creates /data owned by apache:apache with mode 0755.

  5. Sets APACHE_RUN_USER and APACHE_RUN_GROUP in /etc/apache2/envvars, so Apache runs as apache:apache instead of the Ubuntu default www-data.

  6. Deploys a virtual host (data.conf) with DocumentRoot /data, directory listing disabled and index.html as the index file.

  7. Disables the default site and enables the new one.

  8. Validates the configuration with apache2ctl configtest.

  9. Ensures Apache is enabled and running, then applies the pending restart immediately so the user change takes effect before the checks.

  10. Checks whether /data/index.html exists and prints a reminder if it does not.

  11. Confirms the Apache worker processes run as apache and fails the job if they do not.

  12. Checks that the site returns HTTP 200, only when index.html exists.

The playbook is idempotent. A second run should report no changes once everything is in place.

5. Job Run Result (Job 15)

Status: Successful.

Play recap: ok=16 changed=9 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0

Task Result
Gathering Facts ok
Verify target is Debian/Ubuntu family ok
Install Apache changed
Create apache group changed
Create apache user changed
Create content directory changed
Run Apache as the apache user (envvars) changed (both variables)
Deploy virtual host for /data changed
Disable default site changed
Enable data site changed
Validate Apache configuration ok
Ensure Apache is enabled and running ok
Restart apache (handler) changed
Check whether index.html has been uploaded ok
Remind to upload index.html ok (index.html not found yet)
Confirm Apache worker processes run as apache ok
Check the site responds skipped (no index.html yet)

The confirmation task passing means the Apache worker processes were verified to be running as the apache user. The HTTP check was skipped on purpose because index.html has not been uploaded yet.

AWX job 15 output with Successful status

Figure 2. Job 15 output page, start of the log. Illustrative reproduction using lines from the actual job log.

AWX job 15 output ending with the play recap

Figure 3. Job 15 output, end of the log with the play recap. Illustrative reproduction using lines from the actual job log.

Warnings in the log

The log contains two warnings. Neither affected the result.

  • ANSIBLE_COLLECTIONS_PATHS deprecation warning. The execution environment sets the plural form of this option, which will be removed in ansible-core 2.19. It comes from the environment configuration, not from the playbook.

  • Python interpreter discovery warning. Ansible found /usr/bin/python3.12 automatically. It can be silenced by setting ansible_python_interpreter for the host or group in the inventory.

6. Full Job Log

Show full job log
Identity added: /runner/artifacts/15/ssh_key_data (awx)
[DEPRECATION WARNING]: ANSIBLE_COLLECTIONS_PATHS option. Reason: does not fit 
var naming standard, use the singular form ANSIBLE_COLLECTIONS_PATH instead 
Alternatives: none. This feature will be removed in version 2.19. Deprecation 
warnings can be disabled by setting deprecation_warnings=False in ansible.cfg.

PLAY [Set up Apache web server] ************************************************

TASK [Gathering Facts] *********************************************************
[WARNING]: Platform linux on host absubn02 is using the discovered Python
interpreter at /usr/bin/python3.12, but future installation of another Python
interpreter could change the meaning of that path. See
https://docs.ansible.com/ansible-
core/2.18/reference_appendices/interpreter_discovery.html for more information.
ok: [absubn02]

TASK [Verify target is Debian/Ubuntu family] ***********************************
ok: [absubn02] => {
    "changed": false,
    "msg": "All assertions passed"
}

TASK [Install Apache] **********************************************************
changed: [absubn02]

TASK [Create apache group] *****************************************************
changed: [absubn02]

TASK [Create apache user] ******************************************************
changed: [absubn02]

TASK [Create content directory] ************************************************
changed: [absubn02]

TASK [Run Apache as the apache user (envvars)] *********************************
changed: [absubn02] => (item={'key': 'APACHE_RUN_USER', 'value': 'apache'})
changed: [absubn02] => (item={'key': 'APACHE_RUN_GROUP', 'value': 'apache'})

TASK [Deploy virtual host for /data] *******************************************
changed: [absubn02]

TASK [Disable default site] ****************************************************
changed: [absubn02]

TASK [Enable data site] ********************************************************
changed: [absubn02]

TASK [Validate Apache configuration] *******************************************
ok: [absubn02]

TASK [Ensure Apache is enabled and running] ************************************
ok: [absubn02]

TASK [Apply pending restart now (so user change takes effect before checks)] ***

RUNNING HANDLER [Restart apache] ***********************************************
changed: [absubn02]

TASK [Check whether index.html has been uploaded] ******************************
ok: [absubn02]

TASK [Remind to upload index.html] *********************************************
ok: [absubn02] => {
    "msg": "/data/index.html not found - upload it manually (owner apache:apache)."
}

TASK [Confirm Apache worker processes run as apache] ***************************
ok: [absubn02]

TASK [Check the site responds (only once index.html exists)] *******************
skipping: [absubn02]

PLAY RECAP *********************************************************************
absubn02                   : ok=16   changed=9    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   

7. Remaining Steps

  1. Upload index.html to /data on absubn02 and set its ownership: chown apache:apache /data/index.html.

  2. Re-run the job template once the file is in place. The HTTP 200 check will then run instead of being skipped.

  3. Check the firewall. The playbook does not manage it. If UFW is enabled on absubn02, allow port 80.

  4. Optional: set ansible_python_interpreter in the inventory to silence the interpreter discovery warning.

Appendix: 2web.yml

Show 2web.yml
---
# 2web.yml - install and configure Apache on Ubuntu, run as apache:apache,
# serving content from /data. index.html is uploaded manually (not managed here).
#
# AWX job template: set Limit = absubn02 so it only runs on that host.

- name: Set up Apache web server
  hosts: all
  become: true
  gather_facts: true

  vars:
    web_user: apache
    web_group: apache
    web_root: /data
    web_port: 80
    server_name: "{{ inventory_hostname }}"

  tasks:
    - name: Verify target is Debian/Ubuntu family
      ansible.builtin.assert:
        that: ansible_facts['os_family'] == 'Debian'
        fail_msg: "{{ inventory_hostname }} is not Debian/Ubuntu - this playbook uses apache2/a2ensite"

    - name: Install Apache
      ansible.builtin.apt:
        name: apache2
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Create apache group
      ansible.builtin.group:
        name: "{{ web_group }}"
        system: true
        state: present

    - name: Create apache user
      ansible.builtin.user:
        name: "{{ web_user }}"
        group: "{{ web_group }}"
        system: true
        shell: /usr/sbin/nologin
        home: /nonexistent
        create_home: false
        state: present

    - name: Create content directory
      ansible.builtin.file:
        path: "{{ web_root }}"
        state: directory
        owner: "{{ web_user }}"
        group: "{{ web_group }}"
        mode: "0755"

    - name: Run Apache as the apache user (envvars)
      ansible.builtin.lineinfile:
        path: /etc/apache2/envvars
        regexp: "^export {{ item.key }}="
        line: "export {{ item.key }}={{ item.value }}"
      loop:
        - { key: APACHE_RUN_USER, value: "{{ web_user }}" }
        - { key: APACHE_RUN_GROUP, value: "{{ web_group }}" }
      notify: Restart apache

    - name: Deploy virtual host for /data
      ansible.builtin.copy:
        dest: /etc/apache2/sites-available/data.conf
        owner: root
        group: root
        mode: "0644"
        content: |
          # Managed by Ansible - do not edit by hand
          <VirtualHost *:{{ web_port }}>
              ServerName {{ server_name }}
              DocumentRoot {{ web_root }}

              <Directory {{ web_root }}>
                  Options -Indexes +FollowSymLinks
                  AllowOverride None
                  Require all granted
              </Directory>

              DirectoryIndex index.html
              ErrorLog ${APACHE_LOG_DIR}/data-error.log
              CustomLog ${APACHE_LOG_DIR}/data-access.log combined
          </VirtualHost>
      notify: Restart apache

    - name: Disable default site
      ansible.builtin.command: a2dissite 000-default.conf
      args:
        removes: /etc/apache2/sites-enabled/000-default.conf
      notify: Restart apache

    - name: Enable data site
      ansible.builtin.command: a2ensite data.conf
      args:
        creates: /etc/apache2/sites-enabled/data.conf
      notify: Restart apache

    - name: Validate Apache configuration
      ansible.builtin.command: apache2ctl configtest
      changed_when: false

    - name: Ensure Apache is enabled and running
      ansible.builtin.service:
        name: apache2
        state: started
        enabled: true

    - name: Apply pending restart now (so user change takes effect before checks)
      ansible.builtin.meta: flush_handlers

    - name: Check whether index.html has been uploaded
      ansible.builtin.stat:
        path: "{{ web_root }}/index.html"
      register: index_file

    - name: Remind to upload index.html
      ansible.builtin.debug:
        msg: "{{ web_root }}/index.html not found - upload it manually (owner {{ web_user }}:{{ web_group }})."
      when: not index_file.stat.exists

    - name: Confirm Apache worker processes run as apache
      ansible.builtin.shell: >
        ps -eo user:20,comm | awk '$2=="apache2" && $1!="root"{print $1}' | sort -u
      register: worker_users
      changed_when: false
      failed_when: worker_users.stdout.strip() != web_user

    - name: Check the site responds (only once index.html exists)
      ansible.builtin.uri:
        url: "http://localhost:{{ web_port }}/"
        status_code: 200
      when: index_file.stat.exists

  handlers:
    - name: Restart apache
      ansible.builtin.service:
        name: apache2
        state: restarted
← All lab projects

Contact us

Tell us about your project and we'll reply within one business day.

Reach us directly

Email: info@abs-inet.com
Phone: +1 (647) 325-6546
Hours: Mon–Fri, 8:00–18:00