How To

RHCSA Practice Questions: Hands-On EX200 Tasks with Solutions

Many of the RHCSA practice questions that rank today are multiple choice banks written for RHEL 9, while the Red Hat EX200 exam is entirely hands-on. You get a running system and a list of things to configure, and your work has to still be there after the system reboots. A task that worked when you typed it but is gone after a restart is worth the same as a task you never started.

Original content from computingforgeeks.com - post 184484

This page is a set of 20 hands-on tasks with worked solutions, grouped by the RHEL 10 objective areas: files and permissions, DNF and Flatpak, shell scripts, tuned profiles, persistent journals, LVM, NFS with autofs, systemd timers, networking, users and SELinux. Each task gives the statement, the commands, the real output, and how to prove it from a fresh boot. When you’re done, the RHCSA study books for RHEL 10 fill in the theory behind each area. Everything below was run on Rocky Linux 10.2 in November 2026, including three reboots of the practice VM.

Every answer has to survive a reboot

Red Hat’s own EX200 exam page states the rule plainly: configurations must persist after reboot without intervention. That one sentence changes how you should practice. A command like mount, setsebool without -P, or firewall-cmd without --permanent changes the running system and nothing else, so it looks right until the machine restarts.

Grade yourself the same way a strict reviewer would. Work through every task, reboot, and then run only the verification commands, because anything you fix during the check is a fix you wouldn’t get to make on a graded system. These habits kept the lab honest:

  1. Write the change to its file (fstab, a drop-in, a unit, a connection profile)
  2. Enable the service, not just start it
  3. Test the file with the tool’s own check (mount -a, visudo -c, firewall-cmd --reload)
  4. Reboot and verify from the new boot

Step 3 is the one people skip. A typo in /etc/fstab found by mount -a costs you ten seconds. The same typo found by a reboot can leave the system in emergency mode, and then you’re fixing it from a console instead of moving on to the next task.

The practice lab is two small VMs

You need one RHEL 10 compatible machine to work on and a second one that serves NFS. Rocky Linux 10 and AlmaLinux 10 both work; the commands are the same as on RHEL 10 because the packages are rebuilt from the same sources. The lab here used these two hosts:

HostRoleAddressDisks
client01.lab.example.comWhere every task runs10.0.1.50, fd00:10::5020 GB system, two empty 5 GB disks (sdb, sdc)
nfs01.lab.example.comNFS server for the mount and autofs tasks10.0.1.5120 GB system

The tasks are grouped by objective, not by the order you should run them. Several of them use the accounts from Task 18 and the nfs01 name from Task 17, so work in this order, because a missing user or an unresolvable host makes later tasks fail for reasons that have nothing to do with the skill being practiced:

  1. Task 17 (hostname, addresses, hosts entry) and Task 18 (users and groups)
  2. Tasks 1 to 16 in order
  3. Tasks 19 and 20, then the reboot check

The two empty disks matter because the storage tasks partition, format and mount real block devices. Add them in your hypervisor before you start; on Proxmox, KVM or VirtualBox that’s a 30 second change. The server side lives on nfs01, and the NFS client guide for Rocky Linux 10 pairs with it. The lab’s nfs01 was prepared like this before any task ran:

  • /exports/data (mode 777) and /exports/homes exported rw,sync to the lab subnet in /etc/exports, nfs-server enabled
  • A README.txt in /exports/data and remoteuser1/notes.txt under /exports/homes, so Task 13 has something to read
  • allow 10.0.1.0/24 and local stratum 8 appended to chrony.conf, so Task 16 has a time server

firewalld wasn’t running on the lab’s nfs01. If it runs on yours, open the nfs, mountd, rpc-bind and ntp services there too, or Tasks 13 and 16 will fail at the network before they test anything.

One objective is not covered here: interrupting the boot process to regain access to a system. That task happens at the GRUB menu on a console, so there’s no SSH output to show, and you should practice it on your own VM’s console until you can do it without notes. The lab VM also started from a minimal cloud image, which is why a few tasks install a package first. A server built from the full RHEL 10 installer may already have it.

File tasks reward exact syntax

The essential tools objectives look easy, and that’s why they cost marks. Each one has a detail that breaks the obvious answer: find matches its own output, tar needs a compressor binary, and setgid does not grant write access.

Task 1: Find a user’s files, filter a file, create links

Task: Work as root and finish all four parts before you look at the solution. If one part takes you more than a couple of minutes, that’s the command to drill.

  • Copy every regular file owned by natasha into /root/natasha-files, keeping owner and timestamps
  • Write every account whose shell is nologin to /root/nologin-users.txt
  • Hard link that file to /root/nologin-hardlink.txt
  • Symlink /srv/hosts-link to /etc/hosts

The first attempt in the lab used a plain find / -user natasha, and it failed in a way you should see once. The copies landed in /root/natasha-files, which find then reached on its way through the tree, so it tried to copy each file onto itself. Nothing was lost, but cp exited non-zero and printed a wall of errors that would hide a real problem.

cp: '/root/natasha-files/.bash_logout' and '/root/natasha-files/.bash_logout' are the same file
cp: '/root/natasha-files/.bash_profile' and '/root/natasha-files/.bash_profile' are the same file
cp: '/root/natasha-files/.bashrc' and '/root/natasha-files/.bashrc' are the same file

Exclude the target directory and /proc, and send the noise from vanishing processes to /dev/null. The -p on cp is what keeps the owner and timestamps:

sudo mkdir /root/natasha-files
sudo find / -user natasha -type f -not -path '/proc/*' -not -path '/root/natasha-files/*' -exec cp -p {} /root/natasha-files/ \; 2>/dev/null
sudo ls -A /root/natasha-files

Her three dot files and her mail spool arrive. The lab ran this find after Task 3, which is why plan.txt from the shared directory is in the list too; on your first pass it won’t be.

.bash_logout
.bash_profile
.bashrc
natasha
plan.txt

For the filter, anchor the pattern to the end of the line, so a comment or a home directory that merely contains “nologin” can’t match, then cut the first field. You could do the same job with awk, but grep and cut are what most people reach for under time pressure, so practice the version you’ll actually type. Each link then takes one command.

grep -E '^[^#].*nologin$' /etc/passwd | cut -d: -f1 | sudo tee /root/nologin-users.txt
sudo ln /root/nologin-users.txt /root/nologin-hardlink.txt
sudo ln -s /etc/hosts /srv/hosts-link
sudo ls -li /root/nologin-users.txt /root/nologin-hardlink.txt

The proof of a hard link is the same inode number in the first column and a link count of 2:

17716859 -rw-r--r--. 2 root root 152 Nov  5 03:41 /root/nologin-hardlink.txt
17716859 -rw-r--r--. 2 root root 152 Nov  5 03:41 /root/nologin-users.txt

Files on disk persist by themselves, so the reboot check for this task is only ls -li again. What can go wrong is writing the output somewhere under /tmp, because systemd cleans that directory on its own schedule. Write task output under /root or wherever the task names, and you never have to wonder whether a file will still be there when someone checks.

Task 2: Archive /etc with tar and bzip2

Task: Create a bzip2 compressed archive of /etc at /root/etc-backup.tar.bz2 and list its first entries without extracting it.

sudo tar -cjf /root/etc-backup.tar.bz2 /etc

On the minimal Rocky Linux 10 image this fails, because tar does not compress anything itself. It hands the stream to the bzip2 binary, and that binary was not installed, so tar died with a broken pipe and left a zero byte file behind. A zero byte archive at the right path is worse than no archive, because a quick ls makes it look like the task is done.

tar: Removing leading `/' from member names
/bin/sh: line 1: bzip2: command not found
tar: /root/etc-backup.tar.bz2: Cannot write: Broken pipe
tar: Child returned status 127
tar: Error is not recoverable: exiting now

Listing that empty archive gives the error you’ll see in search results, tar (child): bzip2: Cannot exec: No such file or directory. Both messages mean the same thing, and neither one is about your tar syntax. Install the package and run the identical command again.

sudo dnf install -y bzip2
sudo tar -cjf /root/etc-backup.tar.bz2 /etc
sudo tar -tjf /root/etc-backup.tar.bz2 | head -3
sudo ls -lh /root/etc-backup.tar.bz2

Did the archive keep absolute paths? It did not. The member names have no leading slash, which is why extracting this archive later writes into your current directory instead of over the live /etc. That default protects you from a careless restore, so leave it alone unless a task explicitly asks for absolute paths.

etc/
etc/favicon.png
etc/grub2-efi.cfg
-rw-r--r--. 1 root root 3.8M Nov  5 03:41 /root/etc-backup.tar.bz2

Keep the letters straight: -z is gzip, -j is bzip2 and -J is xz. A task that asks for a .tar.gz and gets bzip2 content fails even though the file exists.

Task 3: Build a collaborative directory with the right umask

Task: Set up a shared directory for the sysadmins group. This is the classic team share, and you’ll build the same thing on real file servers.

  • /srv/projects belongs to group sysadmins
  • Members can create files; others get no access
  • New files belong to sysadmins automatically
  • natasha’s new files are group writable by default
sudo mkdir -p /srv/projects
sudo chgrp sysadmins /srv/projects
sudo chmod 2770 /srv/projects
ls -ld /srv/projects

The lowercase s in the group bits is the setgid flag, and the trailing --- locks everyone else out. Mode 2770 packs both rules into one number, so you don’t need a second chmod.

drwxrws---. 2 root sysadmins 6 Nov  5 03:41 /srv/projects

Setgid only decides the group of new files. It does not make them group writable, and you can see that the moment natasha creates a file with her default umask of 0022.

sudo -u natasha touch /srv/projects/plan.txt
sudo ls -l /srv/projects/plan.txt

The file has the right group, but its mode is -rw-r--r--, so harry can read it and can’t edit it. That’s the permission problem this task is really testing. The fix for future files is natasha’s umask, set in her shell startup file, because a setting that lives in a file applies to every login while one typed at a prompt is gone when the shell exits.

-rw-r--r--. 1 natasha sysadmins 0 Nov  5 03:41 /srv/projects/plan.txt

Append the umask to her .bashrc and create a second file through a login shell, so the new setting actually loads.

echo 'umask 007' | sudo tee -a /home/natasha/.bashrc
sudo -iu natasha bash -ic 'umask; touch /srv/projects/budget.txt'
sudo ls -l /srv/projects

The two bash warnings come from forcing an interactive shell without a terminal, so you can ignore them. What matters is 0007, and a new file that is rw-rw---- from birth next to the old one that still isn’t group writable. If you only checked the new file, you’d miss that half of the task is still broken.

bash: cannot set terminal process group (15914): Inappropriate ioctl for device
bash: no job control in this shell
0007
total 0
-rw-rw----. 1 natasha sysadmins 0 Nov  5 03:57 budget.txt
-rw-r--r--. 1 natasha sysadmins 0 Nov  5 03:41 plan.txt

Changing a umask never touches files that already exist, so repair the old file by hand.

sudo chmod g+w /srv/projects/plan.txt
sudo ls -l /srv/projects/plan.txt

The group write bit is now set on plan.txt as well.

-rw-rw-r--. 1 natasha sysadmins 0 Nov  5 03:41 /srv/projects/plan.txt

Why .bashrc and not just running umask 007? Because a umask typed at the prompt dies with that shell. After the reboot, sudo -iu natasha bash -c umask still printed 0007.

Software comes from two places on RHEL 10

RPM repositories are still the main source, but the RHEL 10 objectives add Flatpak remotes and packages alongside them. The two tasks below configure one of each.

Task 4: Configure DNF repositories from a URL

Task: Point dnf at a mirror you’re given. On a disconnected system this is often the first thing you do, because no other package task works until it is right.

  • Add BaseOS and AppStream from the mirror URLs, with GPG checking on
  • Disable the original repositories
  • Install autofs, tuned, firewalld, httpd, flatpak and the SELinux tools from the new ones

Disabling the originals is how you prove the new file works. If they stay enabled, dnf may quietly install from them and hide a typo in your baseurl, so you’d walk away thinking the task passed. Before writing anything, check which GPG key file exists, since the repo file has to name it exactly.

ls /etc/pki/rpm-gpg/

Rocky Linux 10 ships two keys, and the stable one is what you want. Point a repo at the Testing key and dnf refuses every package, so this is worth ten seconds of checking.

RPM-GPG-KEY-Rocky-10
RPM-GPG-KEY-Rocky-10-Testing

Open a new repo file:

sudo vim /etc/yum.repos.d/practice.repo

Add both repositories. On RHEL 10 the key path is /etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release instead:

[practice-baseos]
name=Practice BaseOS
baseurl=https://dl.rockylinux.org/pub/rocky/10/BaseOS/x86_64/os/
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-10

[practice-appstream]
name=Practice AppStream
baseurl=https://dl.rockylinux.org/pub/rocky/10/AppStream/x86_64/os/
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-10

Disable the stock repos, then install from the new ones:

sudo dnf config-manager --set-disabled baseos appstream
sudo dnf repolist
sudo dnf install -y autofs tuned firewalld httpd policycoreutils-python-utils flatpak

Only the practice repos and Extras remain enabled. If you still see baseos or appstream here, the disable did not take and your test proves nothing.

repo id                               repo name
extras                                Rocky Linux 10 - Extras
practice-appstream                    Practice AppStream
practice-baseos                       Practice BaseOS

To confirm the packages really came from your file, ask dnf where each one was installed from:

dnf repoquery --installed --qf '%{name} %{from_repo}' autofs httpd

autofs came from BaseOS and httpd from AppStream, which matches where Red Hat files those packages. Had both shown the original repo IDs, your new file would have been ignored, so this line is the real proof for the task.

autofs practice-baseos
httpd practice-appstream

The repo file lives in /etc/yum.repos.d, so dnf repolist after the reboot showed the same three repositories. The CRB repository guide covers the third repo you’ll meet on real servers.

Task 5: Add a Flatpak remote and install an app

Task: Configure the Flathub remote system wide, install GNOME Calculator from it, confirm the install, then remove the app and any runtime nothing else uses.

flatpak remotes
sudo flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
sudo flatpak install -y --noninteractive flathub org.gnome.Calculator
flatpak list --app

The first command errors because the system repository directory does not exist until a remote is added, so that message is normal on a fresh box. The install then pulled the GNOME 51 runtime along with the app, which is most of the download, because every Flatpak app runs on a shared runtime rather than on the host’s libraries.

Adding the Flathub remote and installing GNOME Calculator with flatpak on Rocky Linux 10

The system column matters. Without sudo, flatpak remote-add would create a per-user remote that other accounts can’t see. Removal is two commands, because uninstalling the app leaves its runtime behind:

sudo flatpak uninstall -y --noninteractive org.gnome.Calculator
sudo flatpak uninstall -y --unused --noninteractive
flatpak list

An empty flatpak list means the app and its runtimes are gone. The remote stays configured, which is what the “configure access” half of the objective asks for, so flatpak remotes should still show flathub after the removal. If a practice task only says “remove the app”, stop after the first uninstall and leave the runtime, because removing more than you were asked for can break the next task.

Scripts are judged by output and exit code

A practice script needs four things the objectives name: a condition, a loop, positional arguments and command output captured into a variable. Exit codes come for free if you write them deliberately.

Task 6: Write a script that loops over its arguments

Task: Create /usr/local/bin/userinfo.sh with the behaviour below. Putting it in /usr/local/bin means it’s on every user’s PATH, so you can call it by name.

  • No arguments: print usage to stderr, exit 1
  • Each username: print its UID and login shell
  • Unknown user: report it on stderr
  • Exit with the number of users not found
sudo vim /usr/local/bin/userinfo.sh

This is the exact script that ran in the lab:

#!/bin/bash
# userinfo.sh: report UID and shell for each username given as an argument
if [ $# -eq 0 ]; then
    echo "Usage: $0 user1 [user2 ...]" >&2
    exit 1
fi
missing=0
for u in "$@"; do
    if id "$u" &>/dev/null; then
        shell=$(getent passwd "$u" | cut -d: -f7)
        echo "$u uid=$(id -u "$u") shell=$shell"
    else
        echo "$u does not exist" >&2
        missing=$((missing + 1))
    fi
done
exit $missing

Make it executable and test all three paths: no arguments, a mix of real and fake users, and names read from a file:

sudo chmod 755 /usr/local/bin/userinfo.sh
userinfo.sh; echo "exit code: $?"
userinfo.sh natasha sarah ghost; echo "exit code: $?"
printf 'harry\ntempuser\n' > users.txt; userinfo.sh $(cat users.txt); echo "exit code: $?"

Each run returns the exit code the task asked for. A missing user goes to stderr, so you could redirect 2>/dev/null and still get a clean report of the users that exist.

Usage: /usr/local/bin/userinfo.sh user1 [user2 ...]
exit code: 1
natasha uid=1002 shell=/bin/bash
sarah uid=1004 shell=/sbin/nologin
ghost does not exist
exit code: 1
harry uid=1003 shell=/bin/bash
tempuser uid=1005 shell=/bin/bash
exit code: 0

Note the ; echo on the same line. On the first attempt in the lab, echo $? ran in a fresh shell and printed 0 after both runs, because $? only holds the exit code of the previous command in the same shell. That mistake would have made a broken script look correct. Quote "$@" as well, or a username containing a space splits into two arguments and your missing-user count goes wrong.

A running system keeps its tuning and its logs

These four tasks touch the live system: process priority, the tuned profile, the journal and the boot configuration. Only the first one is allowed to disappear at reboot, because processes do.

Task 7: Find a CPU hog, renice it, kill it

Task: Start sha256sum /dev/zero at nice 10. Identify it as the top CPU consumer, change its niceness to 5, then terminate it.

nohup nice -n 10 sha256sum /dev/zero >/dev/null 2>&1 &
ps -eo pid,ni,pcpu,comm --sort=-pcpu | head -3
sudo renice -n 5 -p $(pgrep -x sha256sum)

The process sits at the top with nice 10 and a full core, and renice confirms the change from 10 to 5.

    PID  NI %CPU COMMAND
  15282  10 99.5 sha256sum
  15180   0  4.4 firewalld
15282 (process ID) old priority 10, new priority 5

Moving from 10 to 5 raises the priority, which is why renice needs sudo here even though you own the process. With the default resource limits, a regular user can only make a process nicer, never less nice, so if renice fails with permission denied, check which direction you’re going. For the kill, use pgrep -x rather than pgrep -f. In the lab, pkill -f 'sha256sum /dev/zero' also matched the shell that was running it, because that shell’s own command line contained the same string, and killed it too.

pkill -x sha256sum
pgrep -x sha256sum || echo "no sha256sum left"

The second line prints no sha256sum left once the process is gone. Nothing here needs to persist, because a process never survives a reboot; the skill being practiced is finding the right PID quickly and changing it without guessing.

Task 8: Apply a tuned profile

Task: Enable tuned, find the recommended profile, and set the active profile to throughput-performance.

sudo systemctl enable --now tuned
tuned-adm recommend
sudo tuned-adm profile throughput-performance
tuned-adm active
tuned-adm verify

On a VM, tuned recommends virtual-guest. The switch works, but tuned-adm verify fails, and the log says why:

tuned-adm switching to throughput-performance and tuned-adm verify failing on CPU boost inside a virtual machine

throughput-performance asks for CPU frequency boost, and a virtual CPU exposes no boost control, so the log reads 'boost' = 'None', expected '1' for both vCPUs. Restarting tuned, as the message suggests, gave the same result. That failure says nothing about whether you applied the profile, so don’t burn time on it in a VM. The persistence check is tuned-adm active, and after every reboot it still reported throughput-performance because tuned stores the choice in /etc/tuned/active_profile.

Task 9: Preserve the system journal

Task: Configure journald so logs from previous boots are kept on disk, and prove it with the boot list.

Older notes tell you to edit /etc/systemd/journald.conf. On Rocky Linux 10 that file does not exist, and grepping it fails:

grep: /etc/systemd/journald.conf: No such file or directory

systemd 257 ships its defaults in /usr/lib/systemd/journald.conf, and your local changes go in a drop-in under /etc. The default is Storage=auto, which writes to disk only if /var/log/journal exists. On this box the systemd package owns that directory, so logs were already persistent, but you shouldn’t rely on that: writing the setting down makes it explicit, and it survives someone deleting the directory to free space.

sudo mkdir -p /etc/systemd/journald.conf.d
echo -e '[Journal]\nStorage=persistent' | sudo tee /etc/systemd/journald.conf.d/persistent.conf
sudo systemctl restart systemd-journald
systemd-analyze cat-config systemd/journald.conf | grep -E '^#?Storage|^# /'

The merged view shows the commented default from /usr/lib and your drop-in winning:

# /usr/lib/systemd/journald.conf
# /etc/ if the original file is shipped in /usr/), or by creating "drop-ins" in
#Storage=auto
# /etc/systemd/journald.conf.d/persistent.conf
Storage=persistent

The real test needs a reboot, because a running journald can look persistent while still writing to /run. After the second restart, journalctl --list-boots listed three boots and journalctl -b -1 could read the previous one, so the logs had reached disk. If you only ever see boot 0, your journal is volatile and the task is not done.

Task 10: Set the default target and a kernel argument

Task: Make the system boot to multi-user.target by default and add audit=1 to the kernel command line of every installed kernel.

sudo systemctl set-default multi-user.target
systemctl get-default
sudo grubby --update-kernel=ALL --args="audit=1"
sudo grubby --info=DEFAULT | grep -E '^(kernel|args)'

grubby writes the boot loader entries directly, so the argument is in place before you reboot. You don’t need to rebuild any GRUB config by hand for this.

multi-user.target
kernel="/boot/vmlinuz-6.12.0-211.73.1.el10_2.x86_64"
args="console=ttyS0,115200n8 no_timer_check crashkernel=2G-64G:256M,64G-:512M audit=1"

Prove it from the running kernel after the reboot, not from grubby, because grubby only tells you what the boot entry says. After the restart, grep -o 'audit=1' /proc/cmdline returned audit=1, so the kernel really booted with it. grubby edits the boot entry of each installed kernel directly, which is why it is the simplest answer when a task says “every installed kernel”.

Storage tasks start with a GPT label

The storage objectives now name GPT disks only, and they expect mounts by UUID or label rather than by device name. Device names like /dev/sdb can change order between boots; UUIDs can’t.

Task 11: Create and extend an LVM logical volume

Task: Build an LVM stack on /dev/sdb. Extent size and extent count are deliberate here, because they’re the details a careless answer gets wrong.

  • A 4 GiB GPT partition flagged for LVM
  • Volume group datavg with 8 MiB extents
  • Logical volume datalv of exactly 50 extents, formatted XFS
  • Mounted at /mnt/data by UUID at every boot
  • Grown to 800 MiB without unmounting
sudo parted -s /dev/sdb mklabel gpt mkpart lvm1 1MiB 4GiB set 1 lvm on
sudo pvcreate /dev/sdb1
sudo vgcreate -s 8M datavg /dev/sdb1
sudo lvcreate -n datalv -l 50 datavg
sudo mkfs.xfs -q /dev/datavg/datalv
sudo mkdir -p /mnt/data

Append the fstab line with the UUID pulled from blkid, so you can’t mistype it, then test the file before trusting it:

echo "UUID=$(sudo blkid -s UUID -o value /dev/datavg/datalv) /mnt/data xfs defaults 0 0" | sudo tee -a /etc/fstab
sudo systemctl daemon-reload
sudo mount -a
sudo lvextend -r -L 800M /dev/datavg/datalv

Read the numbers in the capture carefully:

RHCSA LVM practice task on Rocky Linux 10: vgcreate with 8 MiB extents, lvcreate of 50 extents and lvextend -r to 800 MiB

Fifty 8 MiB extents is 400 MiB, yet df reported 336M. That is not a mistake in the task: XFS reserved 64 MiB for its internal log (16,384 blocks of 4 KiB in the xfs_growfs output). If a task says “the volume must be 400 MiB”, check it with lvs, which shows the LV size, not with df. The -r on lvextend runs xfs_growfs for you; without it the LV grows and the filesystem stays at 336M. Remember that XFS can grow but never shrink, so read the target size twice.

Task 12: Add VFAT, ext4 and swap partitions

Task: Add three partitions to /dev/sdc without touching existing swap.

  • 512 MiB VFAT at /mnt/usb, by UUID
  • 1 GiB swap, enabled at boot
  • 1 GiB ext4 labelled archive at /mnt/archive, by label
sudo parted -s /dev/sdc mklabel gpt mkpart usb fat32 1MiB 513MiB mkpart swap2 linux-swap 513MiB 1537MiB mkpart archive ext4 1537MiB 2561MiB
sudo mkfs.vfat -n USBDATA /dev/sdc1
sudo mkswap -L swap2 /dev/sdc2
sudo mkfs.ext4 -q -L archive /dev/sdc3
sudo mkdir -p /mnt/usb /mnt/archive

A VFAT UUID is short, eight hex digits like DB49-FC6C, which surprises people who expect the long form. Add the three fstab lines:

echo "UUID=$(sudo blkid -s UUID -o value /dev/sdc1) /mnt/usb vfat defaults 0 0" | sudo tee -a /etc/fstab
echo "UUID=$(sudo blkid -s UUID -o value /dev/sdc2) none swap defaults 0 0" | sudo tee -a /etc/fstab
echo "LABEL=archive /mnt/archive ext4 defaults 0 2" | sudo tee -a /etc/fstab
sudo systemctl daemon-reload
sudo mount -a
sudo swapon -a
swapon --show
findmnt -t vfat,ext4,xfs -o TARGET,SOURCE,FSTYPE | grep /mnt

Swap is active and all three local filesystems are mounted from fstab. Notice that each one came from fstab through mount -a, not from a manual mount, so the same lines will run at boot.

NAME      TYPE       SIZE USED PRIO
/dev/sdc2 partition 1024M   0B   -2
├─/mnt/archive /dev/sdc3                 ext4
├─/mnt/data    /dev/mapper/datavg-datalv xfs
└─/mnt/usb     /dev/sdc1                 vfat

swapon -a is the swap equivalent of mount -a. It reads fstab, so if it activates the partition, the boot will too, and if it errors you’ve found your typo while the system is still easy to fix. The 0 2 on the ext4 line lets fsck check it at boot after the root filesystem. fsck.xfs does nothing at boot, so the XFS line keeps 0 0.

Network file systems depend on name resolution

An NFS line in fstab names a server, and at boot that name has to resolve before the mount can happen. Keep that in mind here, because it’s the task that failed the reboot check in this lab.

Task 13: Mount an NFS export and automount remote homes

Task: Give client01 access to the shares on nfs01.

  • Mount nfs01:/exports/data on /mnt/shared at every boot
  • Automount nfs01:/exports/homes/remoteuser1 read-write at /rhome/remoteuser1 on first access
showmount -e nfs01
sudo mkdir -p /mnt/shared
echo "nfs01:/exports/data /mnt/shared nfs defaults,_netdev 0 0" | sudo tee -a /etc/fstab
sudo systemctl daemon-reload
sudo mount -a

systemd already treats an nfs line as a network mount, and _netdev makes that dependency explicit, which costs nothing. autofs needs two files: a master entry that owns /rhome, and a map that says what each key under it mounts. Keep them separate, because the master file only points and the map does the work.

echo "/rhome /etc/auto.rhome" | sudo tee /etc/auto.master.d/rhome.autofs
echo "remoteuser1 -rw,sync nfs01:/exports/homes/remoteuser1" | sudo tee /etc/auto.rhome
sudo systemctl enable --now autofs
ls /rhome
cat /rhome/remoteuser1/notes.txt

Look at the empty ls /rhome in the capture. That’s autofs working, not failing:

showmount, a persistent NFS mount and an autofs indirect map mounting a remote home on first access

An indirect map creates the directory only when something asks for it by name, so you test autofs by accessing the full path, never by listing the parent. Listing /rhome and seeing nothing makes many people restart autofs over and over, when nothing is wrong. The mount negotiated NFS 4.2 without being told. The .autofs suffix on the master file is required, because autofs ignores files in /etc/auto.master.d without it.

Scheduled jobs need their daemon installed

The objectives name three schedulers: at, cron and systemd timers. Each one stores its jobs on disk, so they survive a reboot as long as the service that runs them is enabled.

Task 14: Schedule a cron job and a one-off at job

Task: Schedule two jobs.

  • As natasha, log “natasha nightly check” at 02:30 on weekdays
  • One minute from now, write the date to /tmp/at-proof.txt once
echo '30 2 * * 1-5 /usr/bin/logger "natasha nightly check"' | sudo crontab -u natasha -
sudo crontab -l -u natasha
systemctl is-enabled crond atd

crond was enabled, but atd did not exist. The at package is not part of the minimal install, so the first attempt to queue a job failed with bash: line 1: at: command not found.

30 2 * * 1-5 /usr/bin/logger "natasha nightly check"
enabled
not-found

Install it and enable the daemon before you queue anything:

sudo dnf install -y at
sudo systemctl enable --now atd
echo 'date > /tmp/at-proof.txt' | at now + 1 minute
atq

atq shows the job number, the time it will run and the owner. The job file sits in the spool under /var/spool/at, so a reboot before the run time does not lose a queued job. In this lab the job ran at 03:55, two minutes before the first reboot, and /tmp/at-proof.txt still held that timestamp afterwards.

warning: commands will be executed using /bin/sh
job 1 at Thu Nov  5 03:55:00 2026
1	Thu Nov  5 03:55:00 2026 a kenneth

Piping into crontab -u natasha - replaces her whole crontab, which is fine on a practice box and dangerous on a real one. If the user already has entries, use crontab -e -u natasha instead, because overwriting someone’s existing jobs is a mistake you only discover when a backup stops running.

Task 15: Replace a cron job with a systemd timer

Task: Report disk usage on a schedule with systemd.

  • Every 15 minutes, append the usage of /mnt/data to /var/log/disk-report.log
  • Use a systemd timer, not cron
  • A run missed while the machine was off happens at the next boot

A timer needs three pieces: the script, a oneshot service that runs it, and the timer that triggers the service. Create the script first:

sudo vim /usr/local/bin/disk-report.sh

Two lines are enough:

#!/bin/bash
df -h --output=target,pcent /mnt/data >> /var/log/disk-report.log

Make it executable, then create the service unit:

sudo chmod 755 /usr/local/bin/disk-report.sh
sudo vim /etc/systemd/system/disk-report.service

The service has no [Install] section because the timer starts it, not a target:

[Unit]
Description=Append disk usage for /mnt/data

[Service]
Type=oneshot
ExecStart=/usr/local/bin/disk-report.sh

Now the timer, with the same base name so systemd pairs them:

sudo vim /etc/systemd/system/disk-report.timer

Persistent=true is the line that satisfies “a missed run happens at the next boot”:

[Unit]
Description=Run disk-report every 15 minutes

[Timer]
OnCalendar=*:0/15
Persistent=true

[Install]
WantedBy=timers.target

Enable the timer, not the service, and run the service once by hand to check the script:

sudo systemctl daemon-reload
sudo systemctl enable --now disk-report.timer
systemctl list-timers disk-report.timer
sudo systemctl start disk-report.service
cat /var/log/disk-report.log

The timer is scheduled for the next quarter hour, and the manual run already wrote a line to the log. Running the service by hand matters, because a timer that fires a broken script fails silently every 15 minutes and you’d only notice when someone reads the log.

systemctl list-timers showing a 15 minute OnCalendar timer and the log line written by its service

If you’re unsure what an OnCalendar expression means, ask systemd before you commit to it:

systemd-analyze calendar '*:0/15'

The normalized form spells out that the timer fires at minutes 00, 15, 30 and 45 of every hour:

  Original form: *:0/15
Normalized form: *-*-* *:00/15:00
    Next elapse: Thu 2026-11-05 04:15:00 UTC
       From now: 7min left

After the final reboot, list-timers showed both the next run and the last one at 03:53:46, from before the restart. That last-run column is what Persistent=true reads, so systemd knows whether a run was missed while the machine was down.

Task 16: Point chrony at a specific time server

Task: Configure client01 to synchronise time only from nfs01 and start chronyd at boot.

sudo sed -i 's/^pool .*/server 10.0.1.51 iburst/' /etc/chrony.conf
grep -E '^(server|pool)' /etc/chrony.conf
sudo systemctl restart chronyd
chronyc sources
systemctl is-enabled chronyd

The ^* marker means chrony selected that source and is syncing from it, and chronyd was already enabled at boot on this image:

server 10.0.1.51 iburst
MS Name/IP address         Stratum Poll Reach LastRx Last sample
===============================================================================
^* nfs01.lab.example.com         3   6    17     2  +5609ns[  +14us] +/-   29ms
enabled

What does ^? mean instead? It means the server is unreachable or not serving time, and on nfs01 that’s fixed by an allow line for your subnet in its own chrony.conf. Replacing the pool line, rather than adding a server below it, is what makes “only from nfs01” true, because otherwise chrony keeps the public pool as a second source. Rocky’s default file also has sourcedir /run/chrony-dhcp, which adds NTP servers handed out by DHCP; with the static address from Task 17 in place, nothing arrives there.

Network settings live in the connection profile

NetworkManager keeps every setting in a connection profile on disk, so nmcli connection modify is persistent and ip address add is not. The nmcli cheat sheet has the rest of the syntax.

Task 17: Set static IPv4 and IPv6, hostname and a hosts entry

Task: Configure client01’s identity and addresses.

  • Hostname client01.lab.example.com
  • Static 10.0.1.50/24, gateway and DNS 10.0.1.1
  • IPv6 address fd00:10::50/64
  • The name nfs01 resolves to 10.0.1.51

Use your own lab subnet, gateway and DNS server in place of the 10.0.1.x values. If you’re connected over SSH and the new address differs from the current one, run this from the hypervisor console, because nmcli connection up moves the address and your session goes with it. Find your profile name with nmcli connection show first. In this lab it was “cloud-init eth0”; on an installer-built system it is usually the device name. Change all the settings in one modify, so the profile is never left half configured, then bring the profile up.

sudo hostnamectl set-hostname client01.lab.example.com
sudo nmcli connection modify "cloud-init eth0" ipv4.method manual ipv4.addresses 10.0.1.50/24 ipv4.gateway 10.0.1.1 ipv4.dns 10.0.1.1 ipv6.method manual ipv6.addresses fd00:10::50/64
sudo nmcli connection up "cloud-init eth0"
nmcli -f ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns,ipv6.method,ipv6.addresses connection show "cloud-init eth0"

Both methods read manual, which is the setting that survives a reboot. If ipv4.method still said auto, DHCP would replace your address at the next boot, so check the method before you check the address.

ipv4.method:                            manual
ipv4.addresses:                         10.0.1.50/24
ipv4.gateway:                           10.0.1.1
ipv4.dns:                               10.0.1.1
ipv6.method:                            manual
ipv6.addresses:                         fd00:10::50/64

Add the hosts entry and check it the way the system resolver sees it:

echo "10.0.1.51 nfs01.lab.example.com nfs01" | sudo tee -a /etc/hosts
getent hosts nfs01

getent follows /etc/nsswitch.conf exactly like mount.nfs and ssh do, so it’s a better test than ping or dig:

10.0.1.51   nfs01.lab.example.com nfs01

This entry is what broke at the first reboot, and the reboot section below shows why. Profiles are also bound to the interface’s MAC address here, which turned out to matter: after cloud-init was disabled, the NIC came back as ens18 instead of eth0, and the same profile still applied both static addresses.

Accounts, sudo and keys go together

User tasks are usually the first ones on a practice sheet because later tasks reference the accounts. Build them carefully and the rest of the sheet goes faster.

Task 18: Create users, a sudo group and password ageing

Task: Create the accounts later tasks depend on.

  • Group sysadmins; natasha and harry in it as a secondary group
  • sarah with no interactive shell
  • sysadmins members may run any command with sudo
  • natasha’s password expires every 90 days, with 7 days of warning
  • Every new account from now on gets a 60 day maximum
sudo groupadd sysadmins
sudo useradd -G sysadmins natasha
sudo useradd -G sysadmins harry
sudo useradd -s /sbin/nologin sarah
for u in natasha harry sarah; do echo 'Lab-Pass-2026' | sudo passwd --stdin $u; done
id natasha; id harry; getent passwd sarah

The secondary group shows in groups= and sarah’s shell is the last field. Watch the -G flag: a lowercase -g would have changed the primary group instead, and the sudo rule below would still work, so the account looks fine until someone checks for sysadmins as a secondary group.

uid=1002(natasha) gid=1003(natasha) groups=1003(natasha),1002(sysadmins)
uid=1003(harry) gid=1004(harry) groups=1004(harry),1002(sysadmins)
sarah:x:1004:1005::/home/sarah:/sbin/nologin

Put the sudo rule in its own drop-in and let visudo check the syntax. A broken file in /etc/sudoers.d can stop sudo from working for everyone, including you, and then you need root’s password or a console to recover.

echo '%sysadmins ALL=(ALL) ALL' | sudo tee /etc/sudoers.d/sysadmins
sudo chmod 0440 /etc/sudoers.d/sysadmins
sudo visudo -cf /etc/sudoers.d/sysadmins
sudo chage -M 90 -W 7 natasha
sudo chage -l natasha

chage computes the expiry date for you, 90 days from today, so you don’t have to do date arithmetic.

/etc/sudoers.d/sysadmins: parsed OK
Last password change					: Nov 05, 2026
Password expires					: Feb 03, 2027
Password inactive					: never
Account expires						: never
Minimum number of days between password change		: 0
Maximum number of days between password change		: 90
Number of days of warning before password expires	: 7

The default for future accounts lives in /etc/login.defs, and it only applies to users created after the change. So test it with a new account rather than an existing one.

sudo sed -i 's/^PASS_MAX_DAYS.*/PASS_MAX_DAYS\t60/' /etc/login.defs
sudo useradd tempuser
sudo chage -l tempuser | grep Maximum
sudo -l -U harry | tail -2

tempuser picked up 60 days, and harry can run everything through his group membership:

Maximum number of days between password change		: 60
User harry may run the following commands on client01:
    (ALL) ALL

natasha still shows 90, because login.defs never rewrites existing accounts. Mix those two up and you’ll either change every user or none, so read whether a task says “natasha” or “new users”.

Task 19: Configure key-based SSH between two accounts

Task: Generate an Ed25519 key pair for natasha with no passphrase and allow it to log in as harry without a password.

sudo -u natasha ssh-keygen -t ed25519 -N '' -f /home/natasha/.ssh/id_ed25519 -q
sudo install -d -m 700 -o harry -g harry /home/harry/.ssh
sudo cat /home/natasha/.ssh/id_ed25519.pub | sudo tee -a /home/harry/.ssh/authorized_keys >/dev/null
sudo chown harry:harry /home/harry/.ssh/authorized_keys && sudo chmod 600 /home/harry/.ssh/authorized_keys
sudo -u natasha ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes harry@localhost 'whoami; hostname'

BatchMode=yes makes ssh fail instead of prompting for a password, so a success here proves the key did the work and not a typed password.

harry
client01.lab.example.com

install -d creates .ssh with the right owner and mode in one step. sshd refuses keys when .ssh or authorized_keys is writable by anyone but the owner, and that failure only shows up as a password prompt, so it looks like the key was never copied. When key login falls back to a password, check modes before you regenerate anything.

SELinux blocks what the firewall allows

firewalld decides which packets reach the machine. SELinux decides which process may bind a port or read a file. A web server on a non-standard port needs both, and checking only the firewall is how people end up setting SELinux to permissive.

Task 20: Serve a site on port 82 with SELinux enforcing

Task: Publish a small site the strict way.

  • Serve /srv/web with httpd on TCP port 82
  • Open the port in firewalld
  • Keep SELinux enforcing
  • Allow httpd to serve home directories, persistently

Confirm the mode first. getenforce shows the running mode and the config file shows the boot mode, and they can differ:

getenforce
grep ^SELINUX= /etc/selinux/config

Both say enforcing, so there’s nothing to change. If the running mode says Permissive while the file says enforcing, someone ran setenforce 0, and the next boot will put you back in enforcing whether your task was ready for it or not.

Enforcing
SELINUX=enforcing

Open the firewall permanently. firewalld was not installed on the minimal image, so it came in with Task 4:

sudo systemctl enable --now firewalld
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-port=82/tcp
sudo firewall-cmd --reload
sudo firewall-cmd --list-ports

--list-ports prints 82/tcp only if the permanent rule loaded on reload. Now create the site and the virtual host:

sudo mkdir -p /srv/web
echo "practice site on port 82" | sudo tee /srv/web/index.html
sudo vim /etc/httpd/conf.d/practice.conf

Add the listener and the virtual host:

Listen 82
<VirtualHost *:82>
    DocumentRoot /srv/web
    <Directory /srv/web>
        Require all granted
    </Directory>
</VirtualHost>

Start httpd and let it fail, because the failure tells you what to fix:

sudo systemctl enable --now httpd
sudo journalctl -u httpd -n 6 --no-pager | grep -E 'AH0|denied'
sudo ausearch -m AVC -ts today -c httpd | grep -o 'avc:.*' | head -2

The capture shows the whole chain, from the failed start to the working page:

httpd failing to bind port 82 with an SELinux name_bind AVC, then semanage port and restorecon fixing it on Rocky Linux 10

Two separate SELinux problems hide in that chain, and each has its own fix. The first stops httpd from starting at all, the second only shows up as a 403 once it runs.

Error: “(13)Permission denied: AH00072: make_sock: could not bind to address 0.0.0.0:82”

httpd runs as httpd_t, and port 82 is labelled reserved_port_t, so the AVC denies name_bind. Port 81 would have worked, because it is already in http_port_t, but 82 is not. Add the label and restart.

sudo semanage port -a -t http_port_t -p tcp 82
sudo semanage port -l | grep -w http_port_t
sudo systemctl restart httpd
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:82/

httpd now starts, but the page returns 403. The files were created under /srv, so they carry var_t, which httpd may not read. chcon would fix this only until the next relabel, while semanage fcontext writes the rule into policy, so restorecon applies it now and every time after.

sudo semanage fcontext -a -t httpd_sys_content_t "/srv/web(/.*)?"
sudo restorecon -Rv /srv/web
curl -s http://localhost:82/

restorecon reports both relabels, and the page comes back.

Relabeled /srv/web from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
Relabeled /srv/web/index.html from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
practice site on port 82

The boolean is the last part. -P writes it to the policy store; without it the value resets at boot. semanage boolean -l -C lists only booleans changed from the default, which makes it a quick audit of everything you touched.

sudo setsebool -P httpd_enable_homedirs on
sudo semanage boolean -l -C
ps -eZ | grep -m2 httpd

The boolean shows on in both the current and default columns, and the httpd processes run in the httpd_t domain. A boolean set without -P doesn’t appear in the -C list at all, so if getsebool says on but -C doesn’t list it, you forgot -P and the value resets at boot.

SELinux boolean                State  Default Description

httpd_enable_homedirs          (on   ,   on)  Allow httpd to enable homedirs
system_u:system_r:httpd_t:s0      15520 ?        00:00:00 httpd
system_u:system_r:httpd_t:s0      15521 ?        00:00:00 httpd

To practice the mode switch, sudo setenforce 0 printed Permissive and sudo setenforce 1 put it back to Enforcing. setenforce never touches /etc/selinux/config, so it changes nothing after a reboot. The SSH port change with SELinux walks through the same port labelling for sshd.

The reboot check catches what you missed

With all 20 tasks done, the VM was rebooted from Proxmox and checked with read-only commands. Nineteen tasks held, and the NFS mount did not.

After reboot the NFS mount unit failed because mount.nfs could not resolve nfs01 after cloud-init rewrote /etc/hosts

The failed unit is mnt-shared.mount, the unit systemd generates from the NFS line in fstab, and its journal names the cause in one line.

Error: “mount.nfs: Failed to resolve server nfs01: Name or service not known”

getent hosts nfs01 returned nothing, and the top of /etc/hosts explained it: cloud-init had regenerated the file from its template at boot and dropped the nfs01 line. The fstab entry was correct, and so was the autofs map, but both name a server that no longer resolved. The autofs path failed for the same reason, so one missing line in one file took out two tasks.

A system installed from RHEL 10 media has no cloud-init rewriting /etc/hosts, so this exact cause is specific to cloud images. The lesson is general though: an NFS task is only as persistent as the name resolution under it. The first fix tried, manage_etc_hosts: false in /etc/cloud/cloud.cfg.d, did not survive the second reboot, because the VM’s own user-data set manage_etc_hosts: true and user-data wins. What worked was disabling cloud-init on the practice VM.

sudo touch /etc/cloud/cloud-init.disabled
echo "10.0.1.51 nfs01.lab.example.com nfs01" | sudo tee -a /etc/hosts
sudo systemctl restart mnt-shared.mount

After the third reboot, every check passed, including both NFS paths and an empty failed-units list. That last line is a good habit on its own: systemctl --failed after any reboot shows the units you broke.

Final persistence check after reboot: mounts, swap, services, tuned profile, journal boots, SELinux and the port 82 site all intact

Use this table as your own grading sheet. Run each check from a fresh boot, and if the result differs, the task was only half done:

TaskCheck after rebootResult in the lab
3 Collaborative directorysudo -iu natasha bash -c umask0007
4 DNF repositoriessudo dnf repolistpractice-baseos, practice-appstream
8 tunedtuned-adm activethroughput-performance
9 Journaljournalctl --list-bootsprevious boots listed
10 Target and kernel argumentsystemctl get-default, grep -o audit=1 /proc/cmdlinemulti-user.target, audit=1
11, 12 Local storagefindmnt, swapon --show/mnt/data, /mnt/usb, /mnt/archive, /dev/sdc2
13 NFS and autofsfindmnt /mnt/shared, cat /rhome/remoteuser1/notes.txtfailed on reboot 1, passed after the fix
14, 15 Schedulerssudo crontab -l -u natasha, systemctl list-timersentry present, timer active
16 chronychronyc sources^* nfs01
17 Networkinghostnamectl hostname, ip -br addressclient01, 10.0.1.50 and fd00:10::50
20 SELinux and firewallgetenforce, curl http://localhost:82/Enforcing, page served

Tasks 1, 2, 5, 6, 18 and 19 write ordinary files and accounts, which persist on their own, and Task 7 is meant to end with the process gone. Those still deserve a glance after the reboot, because a file written to the wrong path persists just as reliably as one written to the right path.

Theory fills the gaps these tasks expose

If a task here took you more than one attempt, that area needs reading, not more repetition. Practice shows you which command to type; a study guide explains why the alternative fails, which is what you need when a task is worded differently from the one you rehearsed. The best RHCSA books for RHEL 10 are the next step, and the Linux certification books roundup covers LPIC and Linux+ if you’re comparing paths. Readers coming from networking can find the same format for Cisco on the CCNA practice questions page.

Then reset your VM and do all 20 again without this page open, using only man and /usr/share/doc. The second run is the one that tells you whether you know the material.

Keep reading

Patch PHP2Shell on RHEL, Rocky Linux and AlmaLinux 10 / 9 / 8 AlmaLinux Patch PHP2Shell on RHEL, Rocky Linux and AlmaLinux 10 / 9 / 8 Create and Configure Bridge Networking For KVM in Linux Virtualization Create and Configure Bridge Networking For KVM in Linux Configure a System-Wide Proxy on Rocky Linux, RHEL and Fedora AlmaLinux Configure a System-Wide Proxy on Rocky Linux, RHEL and Fedora Best RHCSA Books for 2026: RHEL 10 (EX200) and RHCE Study Guides RHEl Best RHCSA Books for 2026: RHEL 10 (EX200) and RHCE Study Guides Install Zabbix 8.0 LTS on Rocky Linux 10 / AlmaLinux 10 AlmaLinux Install Zabbix 8.0 LTS on Rocky Linux 10 / AlmaLinux 10 Install Java 8 on CentOS 8 / RHEL 8 / Rocky Linux 8 CentOS Install Java 8 on CentOS 8 / RHEL 8 / Rocky Linux 8

Leave a Comment

Press ESC to close