Certification track: EX294
Last updated on 2026-10-04 | Edit this page
This page is optional. It’s for learners using this lesson to prepare for Red Hat exam EX294, the Red Hat Certified Advanced System Administrator in Ansible exam, which counts toward Red Hat Certified Engineer in Ansible. Nothing in the core episodes depends on it.
The official
EX294 objectives were checked on 2026-10-04. They describe a
practical exam and require configurations to persist after reboot.
Available exam versions can differ; check the version you book. The
companion’s exam environment pins ansible-core 2.16 for
practice; that pin alone does not establish parity with every available
exam version.
How the exam works, and what that means for practice
- It’s hands-on: you configure real systems with playbooks you write, and they’re checked after a reboot.
- No internet. Product documentation is available, so learn to find
answers with
ansible-doc, not a browser. - Your playbooks run against freshly installed systems, so practice from clean machines, not from something you’ve tweaked by hand.
So practice the same way: from a requirement, without the finished
solution open, using only ansible-doc, then rerun and
reboot to prove it holds.
Route toward passing
Use the public core to learn the mechanics, then do the existing drills below from requirements with solutions closed. Record whether each result was actually executed and independently verified, not merely read. Repeat weak areas from a clean target without AI or the finished role. Do not spend exam study time on the optional Dataverse/Terraform extension unless it helps a specific Ansible gap.
The remaining VM topics at the end of this page are required preparation gaps, not optional evidence you can replace with a green container test. This revision does not supply that VM lab or claim full exam readiness.
The study loop
Use this for every exercise in the lesson and every drill below:
- Specify. Write down the end state and how you’ll check it, before any YAML.
-
Predict. Which tasks will report
changedon the first run? On the second? -
Attempt. Write the smallest piece yourself. Look
things up with
ansible-doc <module>. - Check. Run it. Compare what happened with what you predicted, and check the system itself, not only Ansible’s output.
- Explain. One or two sentences: what surprised you, and why.
- Transfer. A few days later, solve a variation without looking at your earlier solution.
AI tools are useful for review and hints (see the AI episode), but the exam has none. Do at least every other drill without one.
Check what you can do without Claude or ChatGPT
For each existing core exercise or drill, use this routine before opening its solution:
- Write the required state and a check for it in your own words.
- Spend 10–15 minutes attempting it with
ansible-doc, without AI-generated YAML. - If stuck, request one conceptual hint or a documentation pointer. Try again before requesting code; record the assistance you used.
- Run your attempt in the disposable lab. Inspect the resulting state and run again. Explain any difference between your prediction and the evidence.
- Close the code and explain why each module, condition, and handler is needed.
- On another day, repeat from a clean target with a changed filename, value, or host group. Keep the previous solution closed.
Use a small practice record:
| Requirement | Prediction | State evidence | Assistance | Next retry |
|---|---|---|---|---|
| Exact one-line marker | First run changes; second does not | Contents, line count, permissions, two recaps | Unaided / hint / solution | Date and one changed requirement |
A successful run after copying a solution is a worked example, not yet independent practice. A useful checkpoint is whether you can build it, verify it, diagnose a wrong result, and explain it on the later attempt. Record failures too; they identify what to practice next. This is a study aid, not a prediction of your exam score.
When using an assistant as a tutor, give it this instruction:
Give me one requirement from the existing lesson at a time. Do not supply YAML or the solution before I attempt it. Ask for my prediction, code, and observed output. Review my evidence and explanation, and give only one hint if I ask. Later, test the same concept with a small variation and no earlier solution.
Tools: the exam environment
pointcloud-infra has a separate practice environment pinned to ansible-core 2.16:
BASH
cd pointcloud-infra
pixi shell -e exam # ansible-core 2.16 + ansible-navigator
ansible --version
ansible-doc -l | grep firewalld
ansible-navigator --help
The default environment (pixi run ..., ansible-core
2.21) is for running the real project. Newer ansible-core versions
changed some templating behavior, so do exam drills in the
exam environment.
ansible-navigator normally runs playbooks inside an
execution environment (a container image). To practice the command line
without one, add --ee false:
Practiced, mapped, and planned coverage
Completing the six core labs practices variables, templates, handlers, role input validation, file publication, diagnosis, and local rollback. Reading a role that uses another module only maps an objective; it does not demonstrate that you can write it independently. The drills below provide additional practice if you actually execute and verify them. VM, reboot, SSH, and development-container coverage remains separate or planned.
Controller tools (Pixi, ansible-navigator, execution environments, and editor dev containers) run the automation. Managed targets (Molecule containers or lab VMs) receive it. A controller execution environment is not itself a managed-node lab.
Objective map
The Drill notes below refer to the numbered drills further down this page. Objectives are grouped as on Red Hat’s EX294 page (checked 2026-10-04). Where shows where you can see or practice each one in this lesson or in pointcloud-infra. Gap means you need a separate drill.
| Objective | Where | Gap and drill |
|---|---|---|
| Core components: inventories, modules, variables, facts, loops, conditionals, plays, task failure, playbooks, config files, roles, documentation | Episode: First converge. pointcloud-infra
ansible/roles/potree/ uses dnf,
template, copy, file,
systemd_service, firewalld, uri,
unarchive, stat, find; loops in
tasks/content.yml; conditionals throughout |
Failure handling: the role uses block and
failed_when but not rescue/always
or ignore_errors. Drill: a play that tries a package from
an unavailable repo, reports clearly, and continues |
Configuration: ansible.cfg,
ansible-navigator.yml, static inventory files, host
groups |
pointcloud-infra/ansible/ansible.cfg |
The project uses a dynamic AWS inventory and no
ansible-navigator.yml. Drill: write a static inventory with
two groups and a group of groups, plus an
ansible-navigator.yml that sets the inventory and mode |
| Managed nodes: configure nodes, SSH keys, privilege escalation, deploy files |
become: true in playbooks/site.yml
|
Drill (exam lab): create an automation user on each node, distribute its SSH key, give it passwordless sudo, then switch your inventory to use it |
Running playbooks: ansible-navigator
and ansible-playbook, find modules in collections, create
inventories, configure the environment |
pixi run deploy-check,
ansible/requirements.yml
|
Drill: run the same playbook with both tools; use
ansible-navigator collections and ansible-doc
to find a module you haven’t used |
| Source control: Git clone and add; VS Code to create playbooks and push | Basic cloning and learner branches in the core; commits/pushes are not demonstrated by these labs | Drill: set up VS Code with the Ansible extension and write one drill in it |
| Playbook creation: common modules, registered results, conditionals, error handling, system configuration |
register + when in
tasks/release.yml and tasks/apache.yml
|
Practice only if the relevant drills are completed and verified |
| Roles and collections: create and use roles, install roles, install collections, related content | The potree role; meta/argument_specs.yml;
requirements.yml
|
Drill: install a role from Ansible Galaxy with
ansible-galaxy role install and use it |
| RHCSA tasks: packages/repos, services, firewall, file systems, storage, file content, archiving, scheduling, security, users/groups | EPEL and packages (tasks/packages.yml); services and
timers; firewalld; ini_file, copy,
template; unarchive; SELinux checks
(tasks/selinux.yml) |
Not in the project: storage and file systems
(community.general.lvg/lvol,
filesystem, mount), lineinfile,
archive, cron,
user/group,
seboolean/sefcontext. Drills on lab VMs
(containers can’t do storage or reboots) |
| Content: templates, Ansible Vault | templates/pointcloud.conf.j2 |
Vault: the project has no secrets. Drill: encrypt a dummy variable file, use it in a play, show the plaintext never appears in output |
Drills
Each drill is a requirement written the way the exam phrases tasks,
with checks you run yourself and a time limit. Work from the requirement
and ansible-doc only. Open the solution after you’ve
attempted it, or when the time is up. Then rerun your own version and
confirm the second run reports changed=0.
Drills 1 to 5 run on three practice nodes (containers) on your laptop, so they cover the Ansible mechanics. Storage, SELinux, firewalld and reboot persistence need virtual machines; see the end of this section.
Drill 0: set up the practice nodes (10 minutes)
This is also an exam objective (installing collections), so do it by hand and don’t skip ahead.
BASH
mkdir ~/ex294-drills && cd ~/ex294-drills
IMG=docker.io/geerlingguy/docker-rockylinux10-ansible:latest
for n in node1 node2 node3; do podman run -d --name $n --hostname $n --systemd=always $IMG /usr/sbin/init; done
Create requirements.yml:
YAML
---
collections:
- name: containers.podman
version: "1.20.2"
- name: ansible.posix
version: "2.2.2"
- name: community.general
version: "11.4.9" # last series supporting ansible-core 2.16
Then, from the exam environment (pixi shell -e exam in
pointcloud-infra, then back to this directory):
BASH
ansible --version # should say core 2.16.x
ansible-galaxy collection install -r requirements.yml -p ./collections
Why the pin matters
community.general 12.x needs ansible-core 2.17 or newer,
and 13.x needs 2.18. Installing the newest version into the exam’s 2.16
environment fails or misbehaves. When a module “doesn’t work” and the
code looks right, check the collection’s requires_ansible
in its meta/runtime.yml.
Tear down when you’re done studying:
podman rm -f node1 node2 node3.
INI
# inventory.ini
[web]
node1
node2
[db]
node3
[prod:children]
web
db
[all:vars]
ansible_connection=containers.podman.podman
YAML
# ansible-navigator.yml
---
ansible-navigator:
mode: stdout
execution-environment:
enabled: false
ansible:
inventory:
entries:
- inventory.ini
YAML
# ping.yml
---
- name: Reach every node
hosts: prod
gather_facts: true
tasks:
- name: Show each host's groups
ansible.builtin.debug:
msg: "{{ inventory_hostname }} is in {{ group_names | join(', ') }}"
Plain ansible-playbook finds the inventory and your
installed collections through an ansible.cfg in the same
directory:
The connection variable is only needed because these “servers” are containers; on real machines you’d rely on SSH.
Drill 2: failure handling (15 minutes)
On the web group, with privilege escalation, try to
install the package pointcloud-viewer-extras (it doesn’t
exist). The play must not fail:
- If the install fails, write
pointcloud-viewer-extras: not available on <hostname>to/root/install-report.txt(mode 0600). - Whether or not it fails, print “Optional package step finished on
”.
Checks
- The recap shows
rescued=1andfailed=0for node1 and node2, and the play doesn’t touch node3. -
podman exec node1 cat /root/install-report.txtshows the message. - The “finished” message appears for both web nodes.
YAML
---
- name: Install an optional package, survive if it's missing
hosts: web
become: true
tasks:
- name: Try the optional package, report either way
block:
- name: Install the optional package
ansible.builtin.dnf:
name: pointcloud-viewer-extras
state: present
rescue:
- name: Record the failure
ansible.builtin.copy:
dest: /root/install-report.txt
content: "pointcloud-viewer-extras: not available on {{ inventory_hostname }}\n"
mode: "0600"
always:
- name: Summarize
ansible.builtin.debug:
msg: "Optional package step finished on {{ inventory_hostname }}"
You’ll see a red fatal: line for each node, which is
expected: the failure happens, then rescue handles it.
rescued=1 in the recap is the proof. The copy
task is idempotent, so the report file doesn’t change on a second run,
but the failing dnf task and the rescue still run each
time.
Drill 3: Vault (20 minutes)
This drill uses dummy secrets only. In your fresh practice directory,
use umask 077, create a new .vault-pass
without overwriting an existing file, and exclude
.vault-pass, plaintext secret files, and private keys from
Git before creating them. Stop if these filenames already exist.
Create a whole-file encrypted variables file
secrets.yml containing
app_db_password: "S3cret-drill-only", encrypted with
Ansible Vault using a password file. Write a play for the
db group that creates /etc/drill-app.conf
(root, mode 0600) containing
db_password=<the value>.
Checks
-
head -1 secrets.ymlshows$ANSIBLE_VAULT;..., not your password. - Run
ansible-playbook vault.yml --vault-password-file .vault-pass -v; confirm success and that the dummy value is absent from output. Absence alone is insufficient: a failed run can also print no secret. Do not use debug output to inspect real secrets. -
podman exec node3 stat -c '%a %U' /etc/drill-app.confprints600 root. - A second run reports
changed=0.
BASH
umask 077
printf '\n.vault-pass\nsecrets.yml\ndrill_key\n' >> .gitignore
(set -C; printf '%s\n' 'drill-vault-pass' > .vault-pass)
(set -C; printf 'app_db_password: "S3cret-drill-only"\n' > secrets.yml)
ansible-vault encrypt secrets.yml --vault-password-file .vault-pass
YAML
# vault.yml
---
- name: Configure an app with a secret
hosts: db
become: true
vars_files:
- secrets.yml
tasks:
- name: Write the app config
ansible.builtin.copy:
dest: /etc/drill-app.conf
content: "db_password={{ app_db_password }}\n"
owner: root
group: root
mode: "0600"
no_log: true
no_log: true keeps the password out of task output,
including with -v. Never commit .vault-pass.
In a real repository it’s gitignored and kept somewhere else.
Drill 4: account, key and sudo configuration (25 minutes)
These checks inspect account/key/sudo configuration through Podman.
They do not demonstrate an SSH login as automation or
Ansible privilege escalation from that account. Those require a separate
SSH-capable VM exercise with a non-root login and a become
task that verifies the effective user is root.
On every node in prod:
- A group
operatorswith GID 2000. - Users
automation(UID 2001) andauditor(UID 2002), both inoperators(as a supplementary group), shell/bin/bash. -
automationaccepts an SSH key you generate (ssh-keygen -t ed25519 -N '' -f drill_key). -
automationhas passwordless sudo, and the sudoers file is validated before it’s installed.
Checks
-
podman exec node2 id automationshows UID 2001 and groupsautomationandoperators. -
podman exec node2 sudo -l -U automationends with(ALL) NOPASSWD: ALL. -
podman exec node2 stat -c '%a %U' /home/automation/.ssh/authorized_keysprints600 automation. - A second run reports
changed=0on all three nodes.
YAML
---
- name: Operator accounts
hosts: prod
become: true
vars:
operators:
- name: automation
uid: 2001
- name: auditor
uid: 2002
tasks:
- name: Operators group exists
ansible.builtin.group:
name: operators
gid: 2000
- name: Operator users exist
ansible.builtin.user:
name: "{{ item.name }}"
uid: "{{ item.uid }}"
groups: operators
append: true
shell: /bin/bash
loop: "{{ operators }}"
loop_control:
label: "{{ item.name }}"
- name: Automation user accepts the control node's key
ansible.posix.authorized_key:
user: automation
key: "{{ lookup('ansible.builtin.file', 'drill_key.pub') }}"
- name: Sudo is installed
ansible.builtin.dnf:
name: sudo
state: present
- name: Automation user has passwordless sudo
ansible.builtin.copy:
dest: /etc/sudoers.d/automation
content: "automation ALL=(ALL) NOPASSWD: ALL\n"
owner: root
group: root
mode: "0440"
validate: /usr/sbin/visudo -cf %s
validate: matters: a syntax error in a sudoers file can
lock everyone out, so Ansible checks the new file before moving it into
place. First-run counts depend on the image and existing state; the user
loop can change multiple users within one task. Verify each required
property and expect changed=0 on the second run.
Drill 5: scheduling, file content, archiving (20 minutes)
On the web group:
-
cronieinstalled andcrondrunning and enabled. - A root cron job named
dnf makecachethat runs/usr/bin/dnf -q makecacheat 02:30 every day. -
/etc/dnf/dnf.confcontains exactly one linemax_parallel_downloads=10, even if the setting is already there with another value. - A gzip archive of
/etc/dnfat/root/dnf-config.tar.gz, mode 0600.
Checks
-
podman exec node1 crontab -lshows30 2 * * * /usr/bin/dnf -q makecache. -
podman exec node1 grep -c max_parallel /etc/dnf/dnf.confprints1. -
podman exec node1 tar -tzf /root/dnf-config.tar.gzlists files underdnf/. - A second run reports
changed=0.
YAML
---
- name: Housekeeping on web servers
hosts: web
become: true
tasks:
- name: Cron is installed and running
ansible.builtin.dnf:
name: cronie
state: present
- name: Cron service is enabled
ansible.builtin.service:
name: crond
state: started
enabled: true
- name: Nightly metadata refresh
ansible.builtin.cron:
name: dnf makecache
user: root
minute: "30"
hour: "2"
job: /usr/bin/dnf -q makecache
- name: Faster downloads
ansible.builtin.lineinfile:
path: /etc/dnf/dnf.conf
regexp: '^max_parallel_downloads='
line: max_parallel_downloads=10
- name: Back up /etc/dnf
community.general.archive:
path: /etc/dnf
dest: /root/dnf-config.tar.gz
format: gz
mode: "0600"
lineinfile with a regexp replaces the
existing line instead of adding a duplicate, which is what keeps it
idempotent. The archive module only rewrites the archive when the
contents change.
Drills that need virtual machines
Containers share the host’s kernel and have no real disks, boot process, or SELinux. These exam topics need VMs, and they’re waiting on the exam lab (Rocky Linux machines built with Terraform):
- Storage: partitions, LVM, file systems, persistent mounts
(
community.general.lvg,lvol,filesystem;ansible.posix.mount) - SELinux: file contexts and booleans
(
community.general.sefcontext,ansible.posix.seboolean,ansible.builtin.command: restorecon) - Firewall rules with
ansible.posix.firewalld, on a machine whose firewall is actually running - “Persists after reboot”: run, reboot, check again
Until the lab exists, read the ansible-doc pages for
those modules and write the playbooks, but don’t treat a playbook as
done until it has been run on a real machine and survived a reboot.
Practice environments
| What you’re practicing | Where |
|---|---|
| Modules, templates, handlers, roles, idempotence | Local staging: pixi run staging-up (Rocky Linux 10
container) |
| Inventories, users, Vault, cron, failure handling, collections, navigator | The three practice nodes from Drill 0 |
| Storage, file systems, SELinux, firewalld, reboot persistence | Virtual machines. A Terraform-built exam lab (one control node, three or four managed Rocky Linux nodes, destroyed after each session) is planned for this lesson |
| Timed, requirement-only practice | Clean VMs, a requirements list, ansible-doc, a timer,
no AI |
Outside resources
- Red Hat’s EX294 page: the authoritative objective list. Read it again the week you book.
- Lisenet’s EX294 sample exam: free, 18 practical tasks on a control node plus four managed nodes. Written in 2019 for RHEL 8, so use it as a requirements bank, not a guide to current coverage.
- Sander van Vugt’s EX294 video course (2nd edition): guided labs with explained solutions. Check which RHEL and tooling versions it covers before buying.