Red Hat Certified Engineer (RHCE) #19 Full-Length Practice Exam — 15 Tasks with Solutions

18 min read

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 #

  1. 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 node1node4 on three or four cloud VMs. Anyone who has set up inventory, SSH keys, and become by hand will not waver in the exam room.
  2. Always run the playbook you wrote twice. Only when the second run reports changed as 0 is idempotency verified.
Idempotence check — run twice
ansible-navigator run -m stdout site.yml
ansible-navigator run -m stdout site.yml   # second run: confirm changed=0
  1. 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 the inventory, remote_user, and become defaults in ansible.cfg that we set up in this series first lets you cut options from every command.
  2. 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.

#DomainTasksTask numbers
1Environment, inventory, connection21, 2
2Playbooks, variables, templates33, 4, 5
3Control flow, error handling, Vault36, 7, 8
4Roles, collections, system roles39, 10, 11
5RHCSA task automation412, 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 node1node4, 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.

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 = False

Write /home/ansible/exam/inventory.

inventory
[webservers]
node1
node2

[dbservers]
node3

[balancers]
node4

[prod:children]
webservers
dbservers

Verify the configuration.

Verify the inventory
ansible-inventory --graph
ansible prod --list-hosts

Explanation: [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.

Create the directories
mkdir -p group_vars host_vars

Write group_vars/webservers.yml.

group_vars/webservers.yml
package_state: present

Write group_vars/dbservers.yml.

group_vars/dbservers.yml
db_port: 5432

Write host_vars/node1.yml.

host_vars/node1.yml
server_role: primary

Confirm with ad-hoc commands that the variables are visible only on the correct hosts.

Verify the variables
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.

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: restarted
Run and verify idempotence
ansible-navigator run -m stdout web.yml
ansible-navigator run -m stdout web.yml   # changed=0

Explanation: 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.

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 < 2048
Run
ansible-navigator run -m stdout facts.yml

Explanation: 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.

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.

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'
Run and verify idempotence
ansible-navigator run -m stdout motd.yml
ansible-navigator run -m stdout motd.yml   # changed=0

Explanation: 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.

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 }}"
Run and verify idempotence
ansible-navigator run -m stdout users_loop.yml
ansible-navigator run -m stdout users_loop.yml   # changed=0

Explanation: 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.

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_lines
Run
ansible-navigator run -m stdout safe_deploy.yml

Explanation: 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.

Create the vault file
echo 'changeme' > vault_pass.txt
ansible-vault create --vault-password-file vault_pass.txt secret.yml

When the editor opens, enter the following and save.

secret.yml
db_root_password: "S3cret!"

Write vault_use.yml.

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.

Run
ansible-navigator run -m stdout vault_use.yml --vault-password-file vault_pass.txt

Explanation: 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.

Scaffold the role
ansible-galaxy init roles/apache

Write roles/apache/defaults/main.yml.

roles/apache/defaults/main.yml
apache_port: 80

Write roles/apache/tasks/main.yml.

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

Write roles/apache/templates/listen.conf.j2.

roles/apache/templates/listen.conf.j2
Listen {{ apache_port }}

Write roles/apache/handlers/main.yml.

roles/apache/handlers/main.yml
---
- name: Restart httpd
  ansible.builtin.service:
    name: httpd
    state: restarted

Write use_role.yml.

use_role.yml
---
- name: Apply apache role
  hosts: webservers
  roles:
    - role: apache
      apache_port: 8080
Run and verify idempotence
ansible-navigator run -m stdout use_role.yml
ansible-navigator run -m stdout use_role.yml   # changed=0

Explanation: 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/requirements.yml
---
collections:
  - name: community.general
  - name: ansible.posix

Install into collections under the working directory.

Install the collections
ansible-galaxy collection install -r collections/requirements.yml -p ./collections

Check the installed collections and module.

Verify the installation
ansible-galaxy collection list
ansible-doc ansible.posix.firewalld

Explanation: 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.

Check the system roles package
sudo dnf install -y rhel-system-roles
ansible-galaxy collection list | grep rhel_system_roles

Write timesync.yml.

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.timesync
Run and verify idempotence
ansible-navigator run -m stdout timesync.yml
ansible-navigator run -m stdout timesync.yml   # changed=0

Explanation: 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.

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 }}"
Run and verify idempotence
ansible-navigator run -m stdout accounts.yml
ansible-navigator run -m stdout accounts.yml   # changed=0

Explanation: 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.

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: mounted
Run and verify idempotence
ansible-navigator run -m stdout storage.yml
ansible-navigator run -m stdout storage.yml   # changed=0

Explanation: 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.

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: present
Run and verify idempotence
ansible-navigator run -m stdout firewall_selinux.yml
ansible-navigator run -m stdout firewall_selinux.yml   # changed=0

Explanation: 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.

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: present
Run and verify idempotence
ansible-navigator run -m stdout cron.yml
ansible-navigator run -m stdout cron.yml   # changed=0

Explanation: 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.

DomainTasks , pointsSubtotal
Environment, inventory, connection1(16) , 2(16)32
Playbooks, variables, templates3(20) , 4(16) , 5(20)56
Control flow, error handling, Vault6(20) , 7(20) , 8(20)60
Roles, collections, system roles9(20) , 10(20) , 11(20)60
RHCSA task automation12(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.

DomainRelated tasksPosts to review
Environment, inventory, connection1, 2#2 , #3 , #4
Playbooks, variables, templates3, 4, 5#5 , #6 , #7
Control flow, error handling, Vault6, 7, 8#8 , #9 , #10
Roles, collections, system roles9, 10, 11#11 , #12 , #13
RHCSA task automation12, 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.

X