Red Hat Certified Engineer (RHCE) #19 Full-Length Practice Exam — 15 Tasks with Solutions
From #1 the exam introduction through #18 exam tips, we have circled every RHCE domain once. The final post of this series is not one you read but one you solve. Just like the real EX294, it gathers 15 tasks that integrate every domain in one place. These are not multiple choice — they are hands-on scenarios where you write and run playbooks directly on an empty control node, and each task carries a point value.
The recommended time limit is 4 hours, the same as the real exam. The pass line is 210/300 (70%), scored by summing the point values of all 15 tasks. If you get stuck on a task, mark it, move on, and then bank points starting from the high-value tasks you have a feel for — that is the way over the pass line.
RHCE is not an exam about typing one command well; it is an exam that verifies idempotency — that running the same playbook multiple times produces the same result. Write every task with a module where possible, avoid overusing command and shell, and on modern environments the habit of writing modules with their FQCN (Fully Qualified Collection Name), such as ansible.builtin.copy, helps both grading and debugging. For each task, solve it fully on your own first, then unfold the solution. If you read the solution first, your hands never learn it.
How to take it #
- Solving on an environment built from one control node and several managed nodes is closest to the real thing. If that is hard locally, spin up a control node and managed nodes
node1〜node4on three or four cloud VMs. Anyone who has set up inventory, SSH keys, and become by hand will not waver in the exam room. - Always run the playbook you wrote twice. Only when the second run reports
changedas 0 is idempotency verified.
ansible-navigator run -m stdout site.yml
ansible-navigator run -m stdout site.yml # second run: confirm changed=0- Since there is no internet, look up module usage with
ansible-doc <module>. The habit of quickly scanning the EXAMPLES at the bottom of the docs saves time. Applying theinventory,remote_user, andbecomedefaults inansible.cfgthat we set up in this series first lets you cut options from every command. - Solve all 15 to the end, then unfold the solutions and grade them in one pass. Peeking at solutions partway through dulls your sense of the real exam.
Domain distribution #
The 15 tasks are arranged to match the domain weights of the real EX294. RHCSA automation makes up about half of the exam, so it has the most tasks too.
| # | Domain | Tasks | Task numbers |
|---|---|---|---|
| 1 | Environment, inventory, connection | 2 | 1, 2 |
| 2 | Playbooks, variables, templates | 3 | 3, 4, 5 |
| 3 | Control flow, error handling, Vault | 3 | 6, 7, 8 |
| 4 | Roles, collections, system roles | 3 | 9, 10, 11 |
| 5 | RHCSA task automation | 4 | 12, 13, 14, 15 |
The points reflect the domain weights and task difficulty, totaling 300. The scoring criteria are laid out at the end of the post.
Task 1 (16 points): Environment, inventory, connection #
In the working directory /home/ansible/exam, write an ansible.cfg and an inventory. The inventory must contain managed nodes node1〜node4, where node1 and node2 belong to the group webservers, node3 to the group dbservers, and node4 to the group balancers. Also create a group prod that has webservers and dbservers as children. Configure ansible.cfg to use this inventory by default, with remote_user ansible and privilege escalation applied automatically via sudo.
Solution
Write /home/ansible/exam/ansible.cfg.
[defaults]
inventory = ./inventory
remote_user = ansible
host_key_checking = False
[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = FalseWrite /home/ansible/exam/inventory.
[webservers]
node1
node2
[dbservers]
node3
[balancers]
node4
[prod:children]
webservers
dbserversVerify the configuration.
ansible-inventory --graph
ansible prod --list-hostsExplanation: [prod:children] is a group that bundles other groups as children. Setting the inventory value in ansible.cfg to a relative path (./inventory) means it is only recognized when you run from the working directory, so the trap is keeping your run location in the working directory during grading. become = True must go in the [privilege_escalation] section so root privileges apply across every play without a separate declaration.
Task 2 (16 points): Environment, inventory, connection #
Apply the variable package_state: present to the webservers group and the variable db_port: 5432 to the dbservers group. Also apply the host variable server_role: primary to node1 alone. Define them separated into a group_vars and host_vars directory structure, and confirm with ad-hoc commands that each variable is visible only on the correct hosts.
Solution
Create the directories and variable files under the working directory.
mkdir -p group_vars host_varsWrite group_vars/webservers.yml.
package_state: presentWrite group_vars/dbservers.yml.
db_port: 5432Write host_vars/node1.yml.
server_role: primaryConfirm with ad-hoc commands that the variables are visible only on the correct hosts.
ansible node1 -m ansible.builtin.debug -a "var=server_role"
ansible node3 -m ansible.builtin.debug -a "var=db_port"
ansible node2 -m ansible.builtin.debug -a "var=package_state"Explanation: Ansible automatically loads group_vars/<group>.yml and host_vars/<host>.yml when you place them in the same directory as the inventory. The filename must match the group or host name exactly; a typo leaves the variable quietly empty and hard to debug. Host variables have higher precedence than group variables, so if two values overlap on node1, host_vars wins.
Task 3 (20 points): Playbooks, variables, templates #
Write a playbook web.yml that installs and starts httpd on the webservers group. Install the httpd package, leave the service in the enabled and started state, and create /var/www/html/index.html with the content "Welcome to {{ inventory_hostname }}". Add a handler that restarts httpd when index.html changes. The playbook must be idempotent across two runs.
Solution
Write web.yml.
---
- name: Configure web servers
hosts: webservers
tasks:
- name: Install httpd
ansible.builtin.dnf:
name: httpd
state: present
- name: Ensure httpd is enabled and started
ansible.builtin.service:
name: httpd
state: started
enabled: true
- name: Deploy index page
ansible.builtin.copy:
content: "Welcome to {{ inventory_hostname }}\n"
dest: /var/www/html/index.html
notify: Restart httpd
handlers:
- name: Restart httpd
ansible.builtin.service:
name: httpd
state: restartedansible-navigator run -m stdout web.yml
ansible-navigator run -m stdout web.yml # changed=0Explanation: The content of the copy module is for inlining short bodies, and since the content is the same, the second run reports ok, which is idempotent. A handler is triggered by notify only when the task is changed, so on the second run index.html doesn’t change and the handler doesn’t run either. The handler name must match the notify value exactly for it to be called.
Task 4 (16 points): Playbooks, variables, templates #
Write a playbook facts.yml that, on every prod host, gathers that host’s total memory (MB) and primary IPv4 address, and prints a warning message only for hosts with less than 2048MB of memory. The output must include the hostname and the actual memory value.
Solution
Write facts.yml.
---
- name: Report host facts
hosts: prod
tasks:
- name: Show memory and IP
ansible.builtin.debug:
msg: "{{ inventory_hostname }} has {{ ansible_facts['memtotal_mb'] }}MB, IP {{ ansible_facts['default_ipv4']['address'] }}"
- name: Warn on low memory
ansible.builtin.debug:
msg: "WARNING: {{ inventory_hostname }} has only {{ ansible_facts['memtotal_mb'] }}MB"
when: ansible_facts['memtotal_mb'] | int < 2048ansible-navigator run -m stdout facts.ymlExplanation: Facts are gathered automatically at the start of a play via gather_facts (default true), and you access them as a dictionary, like ansible_facts['memtotal_mb']. Running a numeric comparison through the | int filter prevents string/integer confusion. If fact gathering is turned off for a play, you must collect them first with the ansible.builtin.setup module for the variables to be populated.
Task 5 (20 points): Playbooks, variables, templates #
Write a Jinja2 template motd.j2 and a playbook motd.yml. Deploy a notice to /etc/motd on every prod host that includes the hostname, the OS distribution and version, and the CPU count. The template references facts, and the file must be updated when the content changes.
Solution
Write motd.j2.
Welcome to {{ ansible_facts['hostname'] }}
OS: {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}
CPUs: {{ ansible_facts['processor_vcpus'] }}
Managed by Ansible. Do not edit manually.Write motd.yml.
---
- name: Deploy MOTD from template
hosts: prod
tasks:
- name: Template /etc/motd
ansible.builtin.template:
src: motd.j2
dest: /etc/motd
owner: root
group: root
mode: '0644'ansible-navigator run -m stdout motd.yml
ansible-navigator run -m stdout motd.yml # changed=0Explanation: The template module renders a .j2 file on the control node and deploys it to the managed node, and unlike copy it processes variables and control flow. When the rendered result is the same, there is no change on the second run, which is idempotent. mode must be kept as a quoted string ('0644') to avoid octal-interpretation errors.
Task 6 (20 points): Control flow, error handling, Vault #
Write a playbook users_loop.yml that creates three users (alice, bob, carol) defined in the variable list app_users all at once with loop. Each user belongs to the supplementary group developers and has the shell /bin/bash. The developers group must be created before the users.
Solution
Write users_loop.yml.
---
- name: Create users with loop
hosts: webservers
vars:
app_users:
- alice
- bob
- carol
tasks:
- name: Ensure developers group exists
ansible.builtin.group:
name: developers
state: present
- name: Create application users
ansible.builtin.user:
name: "{{ item }}"
groups: developers
append: true
shell: /bin/bash
state: present
loop: "{{ app_users }}"ansible-navigator run -m stdout users_loop.yml
ansible-navigator run -m stdout users_loop.yml # changed=0Explanation: loop iterates over the list and receives each value in the item variable. Without append: true, groups overwrites the user’s supplementary groups wholesale, a trap that drops existing groups. Placing the group-creation task before the user-creation task prevents failures from a missing supplementary group.
Task 7 (20 points): Control flow, error handling, Vault #
Write a playbook safe_deploy.yml. In a block, install the package mariadb-server and start the mariadb service, but if the installation fails, in rescue record the failure to /var/log/deploy-failed.log, and in always print disk usage regardless of the outcome. Also, set changed_when to false on the command task that checks the service status so it isn’t reported as changed when the output string contains active.
Solution
Write safe_deploy.yml.
---
- name: Safe deploy with block/rescue/always
hosts: dbservers
tasks:
- name: Deploy database
block:
- name: Install mariadb-server
ansible.builtin.dnf:
name: mariadb-server
state: present
- name: Start mariadb
ansible.builtin.service:
name: mariadb
state: started
enabled: true
- name: Check service status
ansible.builtin.command: systemctl is-active mariadb
register: svc_status
changed_when: false
rescue:
- name: Record failure
ansible.builtin.copy:
content: "mariadb deploy failed on {{ inventory_hostname }}\n"
dest: /var/log/deploy-failed.log
always:
- name: Report disk usage
ansible.builtin.command: df -h /
register: disk
changed_when: false
- name: Show disk usage
ansible.builtin.debug:
var: disk.stdout_linesansible-navigator run -m stdout safe_deploy.ymlExplanation: When a task in block fails, control moves to rescue, and always always runs regardless of success or failure. Since command and shell always report as changed, attach changed_when: false to read-only checks like a status query that don’t change the system, to preserve idempotency. When rescue handles the error, the play does not end in failure, so partial recovery is possible.
Task 8 (20 points): Control flow, error handling, Vault #
Create a Vault-encrypted variable file secret.yml (variable db_root_password: S3cret!), and in a playbook vault_use.yml read this variable and write it to /root/db_credentials.txt with mode 0600. Assume the vault password is provided via the file vault_pass.txt at run time.
Solution
Create the encrypted variable file.
echo 'changeme' > vault_pass.txt
ansible-vault create --vault-password-file vault_pass.txt secret.ymlWhen the editor opens, enter the following and save.
db_root_password: "S3cret!"Write vault_use.yml.
---
- name: Use a vaulted variable
hosts: dbservers
vars_files:
- secret.yml
tasks:
- name: Write credentials file
ansible.builtin.copy:
content: "root_password={{ db_root_password }}\n"
dest: /root/db_credentials.txt
owner: root
group: root
mode: '0600'Run it together with the vault password file.
ansible-navigator run -m stdout vault_use.yml --vault-password-file vault_pass.txtExplanation: ansible-vault create makes a new encrypted file, while ansible-vault edit edits an existing one. A playbook that uses an encrypted variable stops with a decryption failure if you drop --vault-password-file or --ask-vault-pass at run time. Keeping the credentials file at mode 0600 so other users can’t read it is a grading item.
Task 9 (20 points): Roles, collections, system roles #
Write a role apache directly under roles/apache. This role installs httpd, starts the service, and takes a variable apache_port (default 80) to deploy Listen {{ apache_port }} to /etc/httpd/conf.d/listen.conf via a template. Put a handler inside the role that restarts httpd when the port changes. In a playbook use_role.yml, apply this role to webservers with apache_port: 8080.
Solution
Create the role skeleton.
ansible-galaxy init roles/apacheWrite roles/apache/defaults/main.yml.
apache_port: 80Write roles/apache/tasks/main.yml.
---
- name: Install httpd
ansible.builtin.dnf:
name: httpd
state: present
- name: Configure listen port
ansible.builtin.template:
src: listen.conf.j2
dest: /etc/httpd/conf.d/listen.conf
notify: Restart httpd
- name: Ensure httpd is running
ansible.builtin.service:
name: httpd
state: started
enabled: trueWrite roles/apache/templates/listen.conf.j2.
Listen {{ apache_port }}Write roles/apache/handlers/main.yml.
---
- name: Restart httpd
ansible.builtin.service:
name: httpd
state: restartedWrite use_role.yml.
---
- name: Apply apache role
hosts: webservers
roles:
- role: apache
apache_port: 8080ansible-navigator run -m stdout use_role.yml
ansible-navigator run -m stdout use_role.yml # changed=0Explanation: A role created with ansible-galaxy init has the tasks, handlers, templates, and defaults directories as its standard structure. Variables in defaults/main.yml have the lowest precedence, so the apache_port: 8080 passed from the playbook wins. The template module inside a role looks at roles/apache/templates as its default path, so you don’t need to write a path in src.
Task 10 (20 points): Roles, collections, system roles #
Install collections via a requirements file. Specify community.general and ansible.posix in collections/requirements.yml, and install them into a collections path under the working directory. After installing, confirm that the ansible.posix.firewalld module is available.
Solution
Write collections/requirements.yml.
---
collections:
- name: community.general
- name: ansible.posixInstall into collections under the working directory.
ansible-galaxy collection install -r collections/requirements.yml -p ./collectionsCheck the installed collections and module.
ansible-galaxy collection list
ansible-doc ansible.posix.firewalldExplanation: ansible-galaxy collection install -r installs the collections listed in the requirements file all at once, and -p specifies the install path. Placing them in ansible.cfg’s collections_path or in a collections directory under the working directory lets the playbook recognize them automatically. In the exam, automation content may be provided via a local mirror, so the trap is checking the server option or the offline path.
Task 11 (20 points): Roles, collections, system roles #
Use the timesync role from rhel-system-roles to configure NTP on every prod host. Use a single NTP server, time.example.com. In a playbook timesync.yml, call the redhat.rhel_system_roles.timesync role and specify the server with the variable timesync_ntp_servers.
Solution
Confirm the system roles package is installed.
sudo dnf install -y rhel-system-roles
ansible-galaxy collection list | grep rhel_system_rolesWrite timesync.yml.
---
- name: Configure NTP with timesync system role
hosts: prod
vars:
timesync_ntp_servers:
- hostname: time.example.com
iburst: true
roles:
- redhat.rhel_system_roles.timesyncansible-navigator run -m stdout timesync.yml
ansible-navigator run -m stdout timesync.yml # changed=0Explanation: rhel-system-roles is installed on the control node via the rhel-system-roles package and exposed as the redhat.rhel_system_roles collection. The trap is that timesync_ntp_servers is not a simple string but a list of dictionaries with hostname and iburst keys. The system role manages the chrony configuration idempotently internally, so changed drops to 0 on the second run.
Task 12 (24 points): RHCSA task automation #
Write a playbook accounts.yml that creates the list of users defined in a variable file on every prod host. Each user has a name, UID, and password (already hashed), processed with loop. Assume the password hashes come from a Vault variable in the same manner as Task 8; here, define and use the variable list srv_users directly.
Solution
Write accounts.yml.
---
- name: Provision accounts
hosts: prod
vars:
srv_users:
- name: deploy
uid: 2001
password: "$6$rounds=656000$abcXYZ$hashedvalue1"
- name: backup
uid: 2002
password: "$6$rounds=656000$abcXYZ$hashedvalue2"
tasks:
- name: Create service accounts
ansible.builtin.user:
name: "{{ item.name }}"
uid: "{{ item.uid }}"
password: "{{ item.password }}"
state: present
loop: "{{ srv_users }}"
loop_control:
label: "{{ item.name }}"ansible-navigator run -m stdout accounts.yml
ansible-navigator run -m stdout accounts.yml # changed=0Explanation: The password of the user module must be a SHA-512 hash, not plaintext, generated with a variable wrapped in ansible-vault or with the password_hash filter. If you put in plaintext, that string itself is stored as the hash and login fails. loop_control.label shows only the username in the output instead of a long value like a hash, improving readability.
Task 13 (24 points): RHCSA task automation #
Write a playbook storage.yml that, on the dbservers host, creates a volume group vg_data on the empty disk /dev/vdb, creates a 2G logical volume lv_data inside it, formats it as xfs, and mounts it permanently at /data. The task must be idempotent.
Solution
Write storage.yml.
---
- name: Configure LVM storage
hosts: dbservers
tasks:
- name: Create volume group
community.general.lvg:
vg: vg_data
pvs: /dev/vdb
- name: Create logical volume
community.general.lvol:
vg: vg_data
lv: lv_data
size: 2G
- name: Create xfs filesystem
community.general.filesystem:
fstype: xfs
dev: /dev/vg_data/lv_data
- name: Mount the filesystem
ansible.posix.mount:
path: /data
src: /dev/vg_data/lv_data
fstype: xfs
state: mountedansible-navigator run -m stdout storage.yml
ansible-navigator run -m stdout storage.yml # changed=0Explanation: LVM automation chains community.general’s lvg, lvol, and filesystem with ansible.posix.mount in order. The mount module’s state: mounted mounts it now while also registering it in /etc/fstab to make a permanent mount (the difference between mounted and present is the trap). Pre-installing both collections in Task 10 pays off here.
Task 14 (24 points): RHCSA task automation #
Write a playbook firewall_selinux.yml that allows httpd on the webservers host to use the non-standard port 8080. Permanently allow 8080/tcp in firewalld and apply it immediately, and add the port 8080 to http_port_t in SELinux. Use ansible.posix.firewalld and community.general.seport.
Solution
Write firewall_selinux.yml.
---
- name: Open port 8080 for httpd
hosts: webservers
tasks:
- name: Allow 8080/tcp in firewalld
ansible.posix.firewalld:
port: 8080/tcp
permanent: true
immediate: true
state: enabled
- name: Add SELinux port label for http
community.general.seport:
ports: 8080
proto: tcp
setype: http_port_t
state: presentansible-navigator run -m stdout firewall_selinux.yml
ansible-navigator run -m stdout firewall_selinux.yml # changed=0Explanation: A firewalld change with permanent: true alone is not applied until the next reload, so you must add immediate: true together to open it right now. When you bring up a service on a non-standard port, SELinux will fail to bind unless you add a port type like http_port_t, so register the port label in advance with seport. Both modules are idempotent, so the second run drops to ok.
Task 15 (20 points): RHCSA task automation #
Write a playbook cron.yml that registers a cron job (named daily-backup) in root’s crontab on every prod host to run /usr/local/bin/backup.sh daily at 02:30. The job must be managed idempotently.
Solution
Write cron.yml.
---
- name: Schedule daily backup
hosts: prod
tasks:
- name: Add daily backup cron job
ansible.builtin.cron:
name: daily-backup
minute: "30"
hour: "2"
job: /usr/local/bin/backup.sh
user: root
state: presentansible-navigator run -m stdout cron.yml
ansible-navigator run -m stdout cron.yml # changed=0Explanation: The cron module’s name is recorded as a comment in the crontab and becomes the key that identifies the job, so rerunning with the same name updates it without duplication. Omitting name piles up the same line on every run and breaks idempotency — the most common trap. It is safer to keep minute and hour as strings.
Scoring criteria #
Grade by summing each task’s points. The total is 300, and 210 or higher is the passing zone.
| Domain | Tasks , points | Subtotal |
|---|---|---|
| Environment, inventory, connection | 1(16) , 2(16) | 32 |
| Playbooks, variables, templates | 3(20) , 4(16) , 5(20) | 56 |
| Control flow, error handling, Vault | 6(20) , 7(20) , 8(20) | 60 |
| Roles, collections, system roles | 9(20) , 10(20) , 11(20) | 60 |
| RHCSA task automation | 12(24) , 13(24) , 14(24) , 15(20) | 92 |
| Total | (total) | 300 |
Grading is result-based, just like the real exam. It looks not at how you wrote the playbook but at whether the state actually created on the managed nodes matches the requirements. In particular, the grading script runs the playbook one more time to check idempotency, so any task that still has changed on the second run loses points. Even within a single task, partial credit is split by item — packages, services, files, ports — so even when you’re stuck on one item, filling in the parts you can to the end is better for your score.
Reviewing weak domains #
After grading, go back to the corresponding post in the table below for any low-scoring domain and review it.
| Domain | Related tasks | Posts to review |
|---|---|---|
| Environment, inventory, connection | 1, 2 | #2 , #3 , #4 |
| Playbooks, variables, templates | 3, 4, 5 | #5 , #6 , #7 |
| Control flow, error handling, Vault | 6, 7, 8 | #8 , #9 , #10 |
| Roles, collections, system roles | 9, 10, 11 | #11 , #12 , #13 |
| RHCSA task automation | 12, 13, 14, 15 | #14 , #15 , #16 , #17 |
If you ran short on time on a particular task, it may be a matter of hand speed rather than domain knowledge. In that case, re-read #1 setup and #18 time management, and solve the same 15 tasks once more against the clock. Once LVM automation and system roles are in your hands, the time per task drops noticeably.
Closing the series #
Starting from the exam introduction in #1, we passed through every RHCE domain across 19 posts — inventory, connection, ad-hoc, playbooks, variables, facts, templates, error handling, conditionals, Vault, roles, collections, system roles, and the four flavors of RHCSA automation. If you cleared 210 points on this mock, you have the skills to clear the pass line in the real exam room too. Congratulations.
Having learned to handle a single system by hand in the RHCSA series and then gained the ability to automate many systems with Ansible in this RHCE series, you have completed the full RHEL certification track. With RHCSA’s manual work and RHCE’s idempotent automation now joined into one, congratulations on stepping onto the path of an automation engineer who manages many systems as code — beyond being an administrator who manages a single system.