sysadmin · May 8, 2026

Build a Multi-User Development Environment

An exam-level RHEL 9 capstone you attempt first, then a deep-dive walkthrough.

RHCSA EX200 Exam · Project 2 of 10
#rhcsa#ex200#rhel9#users#acl#sudo#lvm#systemd#autofs#exam

Scenario: Your team needs a shared development server on devserver1.lab.example.com. You create user accounts with the right group memberships, set up shared storage with correct permissions, configure sudo access, enforce password policies, automate user reporting with a shell script, and make sure the environment is auditable after a reboot.

Domains Covered: All 6 (D1 through D6)

Estimated Time: 90–120 minutes

Difficulty: Exam-level

Rule: Every configuration must survive a reboot. Verify after rebooting.

note: You need a clean RHEL 9 VM with root access, one extra unpartitioned disk at /dev/sdb for Task 3, and internet access for dnf. Two hostnames here are lab placeholders: repo.lab.example.com in Task 9 and fileserver.lab.example.com in Task 10. You configure them exactly as shown, but the package install only completes against a real mirror (point baseurl at your own repositories if you want it to succeed), and the autofs mount stays empty without a matching NFS export. The configuration itself is what gets graded. That is expected.
// what we’re getting into
  1. Part 1: the exam (12 tasks)
  2. Self-check: what success looks like
  3. Part 2: the walkthrough and deep dives
  4. Common mistakes
Part 1: The ExamAttempt every task on your own first. Every configuration must survive a reboot.

Task 1: Create users and groups

Domain 1

Create the following groups and users:

Set the password Dev@12345! for all four users.

Task 2: Configure password policies

Domain 1

Using chage, configure the following for dev1 and dev2:

Configure /etc/security/pwquality.conf to enforce system-wide:

Task 3: Set up shared project storage

Domain 4 + Domain 5

Create a 1 GiB LVM Logical Volume named lv_projects in a new Volume Group vg_dev using /dev/sdb. Format it as XFS. Mount it persistently at /projects using its UUID in /etc/fstab.

Set ownership to root:developers and permissions to 2770 (setGID bit set) so all files created inside inherit the developers group. Create two subdirectories:

Task 4: Configure ACLs on shared storage

Domain 5

On /projects/shared:

On /projects/private:

Task 5: Configure sudo access

Domain 1

Create the file /etc/sudoers.d/developers so that:

Use visudo -f to validate the file. Do not edit /etc/sudoers directly.

Task 6: Write a user activity report script

Domain 2

Create an executable script at /usr/local/bin/user_report.sh that:

Task 7: Schedule the report

Domain 6

Schedule /usr/local/bin/user_report.sh to run:

  1. Every Monday at 6:00 AM via a root crontab entry
  2. At system boot (after the network is available) via a systemd service unit at /etc/systemd/system/user-report.service

Enable the systemd service so it runs on every boot.

Task 8: Configure system logging and journal

Domain 3

Configure rsyslog to write all authpriv messages (auth/sudo events) to /var/log/auth_audit.log. Configure the system journal to be persistent with a maximum size of 400M. Restart both rsyslog and systemd-journald. Verify by running a sudo command as dev1 and confirming the event appears in /var/log/auth_audit.log.

Task 9: Install development tools and configure a local repo

Domain 6

Create a local DNF repo file at /etc/yum.repos.d/devtools.repo pointing to http://repo.lab.example.com/rhel9/AppStream with GPG check disabled. Install the Development Tools package group and the git and python3 packages. Verify installation with gcc —version, git —version, and python3 —version.

Task 10: Configure autofs for developer home directories

Domain 5

Configure autofs with an indirect wildcard map so that accessing /mnt/devhomes/<username> automatically mounts fileserver.lab.example.com:/devhomes/<username>. Enable and start the autofs service. Test by accessing /mnt/devhomes/dev1.

Task 11: Set the hostname and configure NTP

Domain 6

Set the system hostname to devserver1.lab.example.com. Configure chronyd to use time.cloudflare.com iburst. Set the timezone to America/Chicago. Verify NTP is syncing and the hostname is correct after reboot.

Task 12: Reboot and verify everything

All Domains

Reboot the system and confirm every configuration survived. Run through the verification checklist in the Outcomes section.

Self-Check: What Success Looks LikeCompare your system against these before reading the walkthrough.

Users and Groups (Tasks 1, 2)

Expected output of id dev1:

uid=7001(dev1) gid=7000(developers) groups=7000(developers)

Expected output of id contractor1:

uid=7004(contractor1) gid=7002(contractors) groups=7002(contractors),7000(developers)

Expected output of chage -l dev1:

Last password change                    : Apr 20, 2026
Password expires                        : Jul 19, 2026
Password inactive                       : Jul 24, 2026
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 : 10

Storage (Task 3)

Expected output of df -h /projects:

Filesystem                    Size  Used Avail Use% Mounted on
/dev/mapper/vg_dev-lv_projects 1.0G   40M  960M   4% /projects

Expected output of ls -ld /projects:

drwxrws---. 4 root developers 35 Apr 20 2026 /projects

Expected output of lvs:

  LV          VG     Attr       LSize
  lv_projects vg_dev -wi-ao---- 1.00g

ACLs (Task 4)

Expected output of getfacl /projects/shared:

# file: projects/shared
# owner: root
# group: developers
# flags: -s-
user::rwx
group::rwx
group:ops:r-x
mask::rwx
other::---
default:user::rwx
default:group::rwx
default:group:ops:r-x
default:mask::rwx
default:other::---

Sudo (Task 5)

Expected behavior, dev1 can run any command:

bash
sudo whoami
# returns: root  (no password prompt)

Expected behavior, ops1 can only run systemctl:

bash
sudo systemctl status sshd    # works, password required
sudo useradd testuser          # fails: not in sudoers for this command

Report Script (Tasks 6, 7)

Manual test:

bash
/usr/local/bin/user_report.sh
cat /var/log/user_report.txt

Expected output format:

=== User Report — Mon Apr 20 06:00:00 EDT 2026 ===
dev1    | UID: 7001 | Group: developers | Home: EXISTS  | Last login: Apr 20
dev2    | UID: 7002 | Group: developers | Home: EXISTS  | Last login: never
ops1    | UID: 7003 | Group: ops        | Home: EXISTS  | Last login: never
contractor1 | UID: 7004 | Group: contractors | Home: EXISTS | Last login: never
Total regular users: 4

Verify cron job:

bash
crontab -l | grep user_report
# 0 6 * * 1 /usr/local/bin/user_report.sh

Verify systemd service:

bash
systemctl is-enabled user-report.service
# enabled

Logging (Task 8)

Verify auth events are logged:

bash
su - dev1 -c "sudo whoami"
grep "dev1" /var/log/auth_audit.log
# Should show sudo session lines

Verify journal persistence:

bash
ls /var/log/journal/
journalctl --disk-usage
# max allowed 400.0M

Packages and Repo (Task 9)

Expected output of dnf repolist:

repo id          repo name
devtools         RHEL 9 AppStream Local

Verify packages:

bash
gcc --version    # gcc (GCC) 11.x.x
git --version    # git version 2.x.x
python3 --version # Python 3.x.x

Autofs (Task 10)

bash
ls /mnt/devhomes/dev1
# Lists dev1's NFS home directory contents

systemctl is-active autofs
# active

Hostname and NTP (Task 11)

bash
hostname
# devserver1.lab.example.com

timedatectl
# Time zone: America/Chicago
# System clock synchronized: yes

chronyc sources
# ^* time.cloudflare.com  ...
Part 2: The WalkthroughStep by step, with a conceptual deep dive before each task.

Task 1: Create users and groups

Domain 1
// deep dive

Every account on a Linux system is really three records that must agree. /etc/passwd holds the username, UID, primary GID, home, and shell. /etc/shadow holds the hashed password and aging fields. /etc/group maps group names to GIDs and lists supplementary members. The kernel only ever deals in numbers, the UID and GID, and the names are a convenience layered on top.

A user has exactly one primary group (set with -g, written into the passwd line and applied to every file the user creates) and any number of supplementary groups (set with -G, listed in /etc/group). That is why contractor1 uses -g contractors -G developers: its identity is contractor, but it also belongs to developers so it can reach the shared storage.

By default RHEL gives each new user a private group of the same name (User Private Groups). Here you override that by naming an existing group with -g, which is exactly what shared-project setups need so that files land in a common group. UIDs below 1000 are reserved for the system; regular users start at 1000, which is why the report script in Task 6 filters on that boundary.

Goal: Create three groups and four users with the right IDs, group memberships, and a shared password.

Step 1.1: Create the groups first

bash
groupadd -g 7000 developers
groupadd -g 7001 ops
groupadd -g 7002 contractors

Groups must exist before any user can be assigned to them. The -g flag pins a specific GID.

Verify:

bash
getent group developers ops contractors

Step 1.2: Create the users

bash
useradd -u 7001 -g developers -s /bin/bash dev1
useradd -u 7002 -g developers -s /bin/bash dev2
useradd -u 7003 -g ops -s /bin/bash ops1
useradd -u 7004 -g contractors -G developers -s /bin/bash contractor1

-g sets the primary group, -G adds supplementary groups. contractor1 has contractors as primary and developers as a supplementary group, so it lands in the dev storage while keeping a contractor identity.

Verify:

bash
id dev1
id contractor1

Expected:

uid=7001(dev1) gid=7000(developers) groups=7000(developers)
uid=7004(contractor1) gid=7002(contractors) groups=7002(contractors),7000(developers)

Step 1.3: Set the password for all four

bash
echo 'Dev@12345!' | passwd --stdin dev1
echo 'Dev@12345!' | passwd --stdin dev2
echo 'Dev@12345!' | passwd --stdin ops1
echo 'Dev@12345!' | passwd --stdin contractor1

passwd —stdin reads the new password from a pipe, which is the fast, scriptable way on RHEL. Dev@12345! already satisfies the complexity policy you set in Task 2 (12 characters, an uppercase letter, a digit, a special character).

checkpoint: id dev1 and id contractor1 must show the correct UID, primary group, and (for contractor1) the developers supplementary group before continuing.

Task 2: Configure password policies

Domain 1
// deep dive

chage edits the aging fields in /etc/shadow. The flags are easy to mix up: -M is the maximum days a password is valid, -m the minimum days between changes, -W the warning window before expiry, and -I the inactive grace period. Inactive is the subtle one: after the password expires, the account stays usable for that many days, then locks. So -M 90 -I 5 means the password dies at day 90 and the account locks at day 95.

System-wide complexity is enforced by pam_pwquality, configured in /etc/security/pwquality.conf and wired into the password stack in /etc/pam.d/system-auth. The credit values are counted in a confusing direction. A negative number is a hard requirement: ucredit = -1 means at least one uppercase letter is required. A positive number instead grants length credit for that class, which is not what you want for an exam policy. One caveat: root can override these rules when setting another user’s password, so always test enforcement as an unprivileged user.

Goal: Per-user password aging for dev1/dev2, plus a system-wide complexity policy.

Step 2.1: Set aging with chage

bash
chage -M 90 -W 10 -I 5 dev1
chage -M 90 -W 10 -I 5 dev2

-M 90 is the maximum password age, -W 10 warns 10 days before expiry, and -I 5 makes the account inactive (locked) 5 days after the password expires.

Verify:

bash
chage -l dev1

Step 2.2: Set system-wide complexity

bash
vim /etc/security/pwquality.conf

Set (uncomment and edit) these lines:

minlen = 12
ucredit = -1
dcredit = -1
ocredit = -1

minlen is the minimum length. A value of -1 for ucredit, dcredit, and ocredit means “require at least one” uppercase letter, digit, and special character respectively. Positive numbers would instead grant length credit, so the negative values are what enforce a requirement.

Step 2.3: Test the policy

bash
su - dev1 -c passwd
# try a weak password like "abc" — it should be rejected
checkpoint: chage -l dev1 shows Maximum 90, Warning 10, Inactive 5. A weak password is rejected for a non-root user.

Task 3: Set up shared project storage

Domain 4 + Domain 5
// deep dive

The storage stack here is the standard LVM chain, physical volume, volume group, logical volume, filesystem, but the interesting part is the permission model. In a shared directory the usual problem is that each user creates files owned by their own primary group, so collaborators cannot edit each other’s work. The fix is the setGID bit on the directory.

chmod 2770 sets three things at once. The 7 gives the owning user full access, the second 7 gives the owning group full access, the 0 denies everyone else, and the leading 2 sets setGID. On a directory, setGID means every file and subdirectory created inside inherits the directory’s group rather than the creator’s primary group. It also propagates: a new subdirectory inherits both the group and the setGID bit, which is why /projects/shared and /projects/private pick up the developers group automatically.

Order matters. Set the group ownership with chown root:developers first, then chmod 2770, otherwise the setGID bit attaches to whatever group the directory happened to have.

Goal: A 1 GiB LVM volume at /projects with a setGID bit, plus two subdirectories.

Step 3.1: Build the LVM volume

bash
pvcreate /dev/sdb
vgcreate vg_dev /dev/sdb
lvcreate -L 1G -n lv_projects vg_dev
mkfs.xfs /dev/vg_dev/lv_projects
mkdir -p /projects

Physical volume, then volume group, then logical volume, then an XFS filesystem on it.

Step 3.2: Mount it persistently by UUID

bash
UUID=$(blkid -s UUID -o value /dev/vg_dev/lv_projects)
echo "UUID=$UUID  /projects  xfs  defaults  0 0" >> /etc/fstab
mount -a
df -h /projects

Mount by UUID, never device path, so a disk reorder cannot break the boot.

Step 3.3: Ownership, setGID, and subdirectories

bash
chown root:developers /projects
chmod 2770 /projects

mkdir /projects/shared /projects/private
chown root:developers /projects/shared /projects/private
chmod 2770 /projects/shared /projects/private

2770 gives the owning group (developers) full access, denies everyone else, and sets the setGID bit so files created inside inherit the developers group. Subdirectories created under a setGID directory inherit both the group and the bit; setting 2770 on them explicitly keeps the owning-group permissions consistent for the ACL work in Task 4.

Verify:

bash
ls -ld /projects /projects/shared /projects/private
checkpoint: df -h /projects shows ~1 G mounted, and ls -ld /projects shows drwxrws--- (the s confirms setGID).

Task 4: Configure ACLs on shared storage

Domain 5
// deep dive

Standard Unix permissions give you exactly three sets of rights: owner, one group, and everyone else. The moment two different groups need different access to the same directory, that model runs out of room. POSIX ACLs add named entries on top, so you can say developers get rwx and ops get r-x on the same folder.

A getfacl listing has a fixed grammar. user:: and group:: are the owning user and owning group (the same bits you see in ls -l). user:name: and group:name: are named entries. other:: is everyone else. The mask:: entry is a ceiling: it caps the effective permission of every named entry and the owning group, so if the mask is r-x a named rwx entry is effectively only r-x.

The key insight for this task is that developers is the owning group, so it is represented by the group:: entry that comes from the 2770 bits. You do not need a named group:developers ACL at all; you only add a named entry for ops. Finally, ACLs apply only to files that exist now. To make new files inherit the rules you set a default ACL with setfacl -d, which acts as a template for anything created later inside the directory.

Goal: On /projects/shared, developers get read/write/execute and ops get read/execute; on /projects/private, ops get nothing. New files must inherit these rules.

Step 4.1: Shared directory

bash
# developers already have rwx as the owning group (the 2770 above)
setfacl -m g:ops:r-x /projects/shared
setfacl -m o::--- /projects/shared

# default ACLs so new files inherit the same access
setfacl -d -m u::rwx,g::rwx,g:ops:r-x,o::--- /projects/shared

Because developers is the directory’s owning group, its rwx comes from the standard group permission bits; you only need a named ACL for ops. The -d entries are the default ACL, applied to anything created inside later.

Step 4.2: Private directory

bash
setfacl -m g:ops:--- /projects/private
setfacl -m o::--- /projects/private
setfacl -d -m u::rwx,g::rwx,g:ops:---,o::--- /projects/private

Ops is explicitly denied. Developers keep access through the owning group.

Step 4.3: Verify

bash
getfacl /projects/shared
getfacl /projects/private

Expected for /projects/shared:

# file: projects/shared
# owner: root
# group: developers
# flags: -s-
user::rwx
group::rwx
group:ops:r-x
mask::rwx
other::---
default:user::rwx
default:group::rwx
default:group:ops:r-x
default:mask::rwx
default:other::---
checkpoint: getfacl /projects/shared shows group:ops:r-x and the default: entries; /projects/private shows group:ops:---.

Task 5: Configure sudo access

Domain 1
// deep dive

A sudoers rule reads as four parts: who where = (run-as) what. So %developers ALL=(ALL) NOPASSWD: ALL means members of the developers group, on any host, may run any command as any user, without a password. The leading % is what makes it a group rule rather than a single user. The ops line lists explicit absolute command paths instead of ALL, which limits that group to exactly those binaries, and because it omits NOPASSWD it still prompts for the user’s own password.

Never edit /etc/sudoers by hand. The bottom of that file contains #includedir /etc/sudoers.d, so dropping a file there is the supported, modular way to add rules. Two hard requirements: the file must be mode 0440 or sudo refuses to read it, and the syntax must be valid or you can lock everyone out of sudo. visudo -f edits a drop-in with a syntax check on save, and visudo -c validates without opening an editor.

One RHEL 9 trap: use /usr/bin/systemctl, not /bin/systemctl. /bin is a symlink to /usr/bin, but sudo matches the command against the path it actually resolves, which is the /usr/bin form, so the /bin spelling can silently fail to match.

Goal: Developers run anything without a password; ops run only systemctl and journalctl, with a password.

Step 5.1: Create the drop-in with visudo

bash
visudo -f /etc/sudoers.d/developers

Add these two lines:

%developers ALL=(ALL) NOPASSWD: ALL
%ops ALL=(ALL) /usr/bin/systemctl, /usr/bin/journalctl

The % marks a group. NOPASSWD: ALL lets developers run any command with no prompt. The ops line lists specific absolute command paths (password required, since there is no NOPASSWD). Use /usr/bin/systemctl, not /bin/systemctl: on RHEL 9 the real binary lives in /usr/bin, and sudo matches the resolved path, so the /bin form can silently fail to match.

Step 5.2: Permissions and validation

bash
chmod 440 /etc/sudoers.d/developers
visudo -c -f /etc/sudoers.d/developers

sudo refuses to read a sudoers file unless it is mode 0440. visudo -c checks syntax without committing you to a broken file.

Step 5.3: Test

bash
su - dev1 -c 'sudo whoami'                 # returns root, no password
su - ops1 -c 'sudo systemctl status sshd'  # works, prompts for ops1's password
su - ops1 -c 'sudo useradd testx'          # denied
checkpoint: dev1 gets root with no prompt; ops1 can run systemctl but is refused useradd.

Task 6: Write a user activity report script

Domain 2
// deep dive

The reliable way to walk a colon-delimited file in bash is while IFS=: read -r f1 f2 f3 …. Setting IFS=: for the duration of read splits each line on colons, and -r stops backslashes from being interpreted. This is more robust than piping through cut or awk per field, and reading from /etc/passwd directly means the script always reflects the current user list instead of a hardcoded one.

Exit codes are the contract a script has with whatever runs it. 0 means success, anything else means failure. This script tests that it can write its report up front and exits 1 if not, so a cron job or systemd unit can detect the failure rather than silently producing nothing.

The last-login field is where the naive version breaks. The last command reads wtmp and, for a user who has never logged in, can return its footer line or empty fields, which a simple emptiness test does not catch and which prints a blank. lastlog is purpose-built for this: it prints the literal text Never logged in for such users, so the script can detect that case and print never cleanly.

Goal: A script that reports every regular user and writes the result to a log.

Step 6.1: Create the script

bash
cat > /usr/local/bin/user_report.sh <<'EOF'
#!/bin/bash
REPORT="/var/log/user_report.txt"

if ! echo "=== User Report — $(date) ===" > "$REPORT"; then
  echo "ERROR: Cannot write to $REPORT"
  exit 1
fi

COUNT=0
while IFS=: read -r username _ uid gid _ home shell; do
  if [ "$uid" -ge 1000 ] && [ "$uid" -lt 65534 ]; then
    group=$(getent group "$gid" | cut -d: -f1)
    [ -d "$home" ] && home_status="EXISTS" || home_status="MISSING"
    ll=$(lastlog -u "$username" 2>/dev/null | tail -n1)
    if echo "$ll" | grep -q "Never logged in"; then
      last_login="never"
    else
      last_login=$(echo "$ll" | awk '{print $4, $5, $6}' | xargs)
      [ -z "$last_login" ] && last_login="never"
    fi
    printf "%-15s | UID: %-6s | Group: %-15s | Home: %-7s | Last login: %s\n" \
      "$username" "$uid" "$group" "$home_status" "$last_login" >> "$REPORT"
    COUNT=$((COUNT + 1))
  fi
done < /etc/passwd

echo "Total regular users: $COUNT" >> "$REPORT"
echo "Report saved to $REPORT"
EOF

chmod +x /usr/local/bin/user_report.sh

Two things worth noting. The write test uses if ! echo … > “$REPORT”, so if the log cannot be written the script reports the error and exits 1. And last-login uses lastlog, not last: lastlog states “Never logged in” plainly, which we map to never. The last approach can leave that field blank for users who never logged in.

Step 6.2: Test it

bash
/usr/local/bin/user_report.sh
cat /var/log/user_report.txt
checkpoint: the report lists dev1 through contractor1 with UID, group, home status, and last login, and ends with Total regular users: N.

Task 7: Schedule the report

Domain 6
// deep dive

This task deliberately uses both schedulers because they answer different questions. cron answers at what clock times. Its five fields are minute, hour, day-of-month, month, and day-of-week. The day-of-week field is a common slip: 0 and 7 both mean Sunday, 1 is Monday, so 0 6 * * 1 is six in the morning every Monday. A root crontab entry is per-user; system-wide jobs can instead live in /etc/cron.d.

systemd answers at what point in the boot and lifecycle. A Type=oneshot service runs a command to completion and then is considered done, which is right for a report generator. WantedBy=multi-user.target in the install section is what systemctl enable hooks into so the unit runs on every boot.

The ordering directive is worth understanding. network.target only means the network stack has been configured, not that it is actually reachable. To wait for real connectivity you order After=network-online.target and also pull it in with Wants=network-online.target, since the online target is not reached unless something asks for it.

Goal: Run the report every Monday at 6 AM, and once at boot.

Step 7.1: Add the cron job

bash
crontab -e

Add:

0 6 * * 1 /usr/local/bin/user_report.sh

Cron fields are minute, hour, day-of-month, month, day-of-week. 1 is Monday.

Verify:

bash
crontab -l | grep user_report

Step 7.2: Create the systemd service

bash
cat > /etc/systemd/system/user-report.service <<'EOF'
[Unit]
Description=User Activity Report
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/user_report.sh

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now user-report.service
systemctl status user-report.service

Type=oneshot is correct for a script that runs and exits. After=network-online.target with the matching Wants= is the proper way to mean “after the network is actually up,” which is stronger than network.target (that only means networking has been configured, not that it is reachable).

checkpoint: crontab -l shows the Monday entry, and systemctl is-enabled user-report.service returns enabled.

Task 8: Configure system logging and journal

Domain 3
// deep dive

RHEL runs two logging systems side by side. systemd-journald collects structured logs from every unit, and rsyslog reads from the journal and writes the traditional text files under /var/log. You configure them separately.

rsyslog routes messages using selectors of the form facility.priority. The facility names the source category and the priority names the severity. authpriv is the facility for authentication and privilege events, which is exactly where login and sudo activity lands, so authpriv.* captures all of it regardless of severity. Putting the rule in a drop-in under /etc/rsyslog.d keeps the main config clean, and rsyslog must be restarted to load it.

journald storage is the other half. By default the journal is volatile, kept in /run and wiped on reboot. Setting Storage=persistent moves it to /var/log/journal, but that directory must exist for persistence to take effect, which is why you create it explicitly. SystemMaxUse=400M caps how much disk the journal may consume so it cannot fill the partition.

Goal: Send auth/sudo events to a dedicated log and make the journal persistent.

Step 8.1: rsyslog drop-in for authpriv

bash
echo 'authpriv.*  /var/log/auth_audit.log' > /etc/rsyslog.d/auth_audit.conf
systemctl restart rsyslog

authpriv is the syslog facility for authentication and sudo events. The drop-in routes all of them to a dedicated file.

Step 8.2: Persistent journal

bash
grep -q '^Storage=' /etc/systemd/journald.conf || echo 'Storage=persistent' >> /etc/systemd/journald.conf
grep -q '^SystemMaxUse=' /etc/systemd/journald.conf || echo 'SystemMaxUse=400M' >> /etc/systemd/journald.conf
mkdir -p /var/log/journal
systemctl restart systemd-journald

Storage=persistent plus the existing /var/log/journal directory is what keeps logs across reboots; the directory is mandatory.

Step 8.3: Test

bash
su - dev1 -c 'sudo whoami'
grep dev1 /var/log/auth_audit.log
checkpoint: auth_audit.log shows a sudo entry for dev1, and journalctl —disk-usage reports a max of 400 M.

Task 9: Install development tools and configure a local repo

Domain 6
// deep dive

A DNF repository is just a directory of packages plus metadata, described to the system by a small .repo file in /etc/yum.repos.d. The fields that matter are the section id in brackets (the short name DNF uses internally), name (a human label), baseurl (where the packages live), gpgcheck (whether to verify package signatures), and enabled. RHEL itself ships two main repos, BaseOS for the core operating system and AppStream for applications and development tools.

Disabling gpgcheck tells DNF to install without verifying signatures. That is acceptable for a trusted internal mirror in a lab, but on production you leave it on so a tampered package is rejected.

A package group is a named bundle, not a single package. Development Tools pulls in gcc, make, and the rest of the build chain in one step. Use dnf group list to see the exact names, since a misspelled group name simply reports nothing found. Always confirm with dnf repolist that your repo is visible before you try to install from it.

Goal: A local repo file, then the Development Tools group plus git and python3.

Step 9.1: Create the repo file

bash
cat > /etc/yum.repos.d/devtools.repo <<'EOF'
[devtools]
name=RHEL 9 AppStream Local
baseurl=http://repo.lab.example.com/rhel9/AppStream
gpgcheck=0
enabled=1
EOF

dnf repolist
note: repo.lab.example.com is a lab placeholder. On a real machine, point baseurl at an actual mirror, or just use your normal RHEL repositories. The installs below pull from whatever repos are enabled.

Step 9.2: Install the tools

bash
dnf group install "Development Tools" -y
dnf install -y git python3

Use dnf group list first if you are unsure of the exact group name.

Step 9.3: Verify

bash
gcc --version
git --version
python3 --version
checkpoint: all three report a version.

Task 10: Configure autofs for developer home directories

Domain 5
// deep dive

You could mount every developer home with a permanent /etc/fstab entry, but that mounts everything at boot whether or not anyone uses it, and it does not scale to dozens of nearly identical mounts. autofs mounts on demand and unmounts again after a period of inactivity, which is lighter and self-healing.

autofs is configured in two layers. The master map (here a drop-in under /etc/auto.master.d) says this mount point is managed by this map file. The map file then describes what to mount. An indirect map manages a set of subdirectories under one parent, which is what you want for per-user homes.

The wildcard is the elegant part. A key of * matches any subdirectory name someone tries to access, and & in the location is replaced by that same name. So one line, * -rw,sync fileserver:/devhomes/&, handles every user: touching /mnt/devhomes/dev1 mounts fileserver:/devhomes/dev1. Because the mount is created on access, listing the parent /mnt/devhomes shows nothing; you must reference a specific subdirectory to trigger it.

Goal: Accessing /mnt/devhomes/<user> auto-mounts that user’s NFS home.

Step 10.1: Install and write the maps

bash
dnf install -y autofs

echo '/mnt/devhomes  /etc/auto.devhomes' > /etc/auto.master.d/devhomes.autofs
echo '*  -rw,sync  fileserver.lab.example.com:/devhomes/&' > /etc/auto.devhomes

systemctl enable --now autofs

This is a wildcard indirect map. The * key matches any subdirectory name under /mnt/devhomes, and & is replaced by that same name, so accessing /mnt/devhomes/dev1 mounts fileserver.lab.example.com:/devhomes/dev1.

Step 10.2: Test

bash
ls /mnt/devhomes/dev1
note: fileserver.lab.example.com is fictional. Without a matching NFS export the mount stays empty, but the configuration is what the exam grades. Access the specific path (/mnt/devhomes/dev1), not /mnt/devhomes alone, or the automounter never fires.
checkpoint: systemctl is-active autofs returns active.

Task 11: Set the hostname and configure NTP

Domain 6
// deep dive

hostnamectl set-hostname writes the name to /etc/hostname, so it is the persistent way to rename a host. The older hostname command only changes the running value and is lost on reboot. systemd actually tracks three hostnames, static (the configured one), transient (a DHCP or runtime value), and pretty (a free-form label), but for the exam the static one set by hostnamectl is what counts. Adding the name to /etc/hosts lets the machine resolve its own fully-qualified name without depending on DNS, which some services expect.

Time on RHEL 9 is handled by chrony. You point it at a source with a server line in /etc/chrony.conf; the iburst option makes the first sync happen quickly by sending a short burst of probes. Comment out the default pool and server lines before adding your own so chrony is not also averaging in the shipped sources. chronyc sources shows the candidates, where a ^* marks the currently selected source, and chronyc tracking shows how closely the clock is disciplined. Timezone is separate from sync: timedatectl set-timezone repoints /etc/localtime at the right zone file.

Goal: Persistent hostname, correct timezone, and a working time source.

Step 11.1: Hostname

bash
hostnamectl set-hostname devserver1.lab.example.com
echo '127.0.0.1  devserver1.lab.example.com devserver1' >> /etc/hosts

hostnamectl writes the change to /etc/hostname so it survives reboot. Adding the name to /etc/hosts lets the box resolve its own fully-qualified name without DNS.

Step 11.2: Timezone and time source

bash
timedatectl set-timezone America/Chicago

sed -i 's/^pool/#pool/'   /etc/chrony.conf
sed -i 's/^server/#server/' /etc/chrony.conf
echo 'server time.cloudflare.com iburst' >> /etc/chrony.conf

systemctl restart chronyd
chronyc sources -v

Comment out the default sources before adding yours, so chrony does not average your chosen server against the ones it is still polling.

checkpoint: hostname returns devserver1.lab.example.com, and timedatectl shows America/Chicago with the clock synchronized.

Task 12: Reboot and verify everything

All Domains
// deep dive

The reboot is not a formality, it is the actual test. Almost every way to fail this kind of exam comes down to the same mistake: a setting that is correct in memory right now but was never written somewhere that survives a restart.

It helps to sort each task into one of two buckets. On-disk state persists by nature: an /etc/fstab line, ACLs stored in the filesystem, a sudoers drop-in, a script on disk, the contents of /etc/chrony.conf. Runtime state does not: a service you started but never enabled, a hostname set with the wrong command, an fstab you edited but never tested with mount -a.

The two verbs that catch most of it are enable (start at every boot) versus start (start right now), and daemon-reload so systemd actually reads a new unit file before you enable it. Reboot, then walk the checklist one line at a time. If something is missing, you know it was runtime-only, and you go back and make it persistent.

bash
reboot

After it comes back, walk the checklist one line at a time:

bash
id dev1                                  # UID/GID correct
id contractor1                           # supplementary group present
df -h /projects                          # LV mounted
getfacl /projects/shared                 # ACLs intact
su - dev1 -c 'sudo whoami'               # returns root, no password
cat /var/log/user_report.txt             # report exists
systemctl is-enabled user-report.service # enabled
cat /var/log/auth_audit.log              # auth events logged
journalctl --disk-usage                  # persistent, max 400M
dnf repolist                             # devtools repo visible
gcc --version                            # development tools installed
systemctl is-active autofs               # autofs running
hostname                                 # devserver1.lab.example.com
chronyc tracking                         # NTP synced
timedatectl | grep "Time zone"           # America/Chicago

If anything failed after reboot

SymptomLikely causeFix
/projects not mountedBad UUID or fstab typoBoot to emergency mode, fix /etc/fstab, reboot
ACLs missingSet on the wrong path, or filesystem mounted without ACL supportRe-run the setfacl commands on /projects/shared and /projects/private
dev1 sudo asks for a passwordsudoers file wrong perms or syntaxchmod 440 and visudo -c -f /etc/sudoers.d/developers
ops1 can run any commandops command list too broad or wrong groupCheck the %ops line and id ops1
Report not generated at bootService not enabled or daemon-reload skippedsystemctl daemon-reload && systemctl enable —now user-report.service
Auth events not in auth_audit.logrsyslog not restartedsystemctl restart rsyslog
autofs not mountingMap path mismatch or missing &Check /etc/auto.devhomes and the master map; access /mnt/devhomes/<user>
Hostname revertedUsed hostname instead of hostnamectlhostnamectl set-hostname devserver1.lab.example.com
Common MistakesThe traps that cost points, and the ones that fail the whole exam.

Task 1: Users and groups

MistakeWhat goes wrongFix
Creating users before groupsuseradd fails, group doesn’t exist yetAlways create groups first
Forgetting -G developers for contractor1User is not in the supplementary groupUse -G for supplementary groups, -g for primary
Using passwd interactively instead of —stdinCan’t script it, slow in examUse echo &#x27;password&#x27; \</td><td>passwd --stdin username
Setting wrong GIDGroup has wrong ID, ACLs may not work correctlyDouble check with getent group groupname

Task 2: Password policies

MistakeWhat goes wrongFix
Confusing chage -M (max days) with -m (min days)Wrong policy applied-M = maximum days, -m = minimum days, -W = warning, -I = inactive
Editing pwquality.conf but leaving values commented outPolicy not enforcedRemove the #, commented lines are ignored
Not testing the policyYou assume it worksTry setting a weak password and verify it’s rejected

Task 3: LVM and storage

MistakeWhat goes wrongFix
Forgetting mount -a after editing fstabMount not active until rebootRun mount -a and check for errors immediately
Setting chmod 770 instead of 2770setGID bit not set, group not inheritedUse 2770, the 2 sets the setGID bit
Not setting group ownership before chmodsetGID applies to wrong groupchown root:developers /projects before chmod

Task 4: ACLs

MistakeWhat goes wrongFix
Setting ACLs but forgetting default ACLs (-d)New files don’t inherit permissionsUse setfacl -d -m for default ACLs
Not setting o::---Others still have accessExplicitly remove other access: setfacl -m o::---
Confusing group ACL with standard group permissionsOne overrides the other unexpectedlyUse getfacl to verify the effective permissions

Task 5: Sudo

MistakeWhat goes wrongFix
Editing /etc/sudoers directly instead of a file in /etc/sudoers.d/Risk of breaking sudo entirely if syntax errorAlways use /etc/sudoers.d/ drop-in files
Forgetting chmod 440 on the sudoers filesudo ignores world-readable sudoers filesSet 440, sudo requires this exact permission
Not running visudo -c -f to validateSyntax errors silently break sudoAlways validate before relying on the config
Using username instead of %groupnameOnly that one user gets access, not the groupPrefix group names with % in sudoers

Task 6: Shell script

MistakeWhat goes wrongFix
Parsing /etc/passwd with cut instead of IFS=: readFragile, breaks with unusual entriesUse while IFS=: read -r … for reliable field splitting
Hardcoding the user listScript breaks when users are added/removedAlways read from /etc/passwd dynamically
Not checking if the report file is writableScript runs but saves nothingTest the write at the start and exit with code 1 if it fails

Task 7: Scheduling

MistakeWhat goes wrongFix
Wrong cron day-of-week for MondayJob runs on wrong day, 1 = Monday in cronDay field: 0=Sun, 1=Mon, 2=Tue … 7=Sun
Enabling service without daemon-reload firstsystemd doesn’t know about the unitAlways daemon-reload before enable or start
Using WantedBy=default.targetMay not work in multi-user environmentsUse WantedBy=multi-user.target for server services

Task 8: Logging

MistakeWhat goes wrongFix
Writing rsyslog rule in /etc/rsyslog.conf directlyWorks but clutters the main configUse drop-in files in /etc/rsyslog.d/
Forgetting to restart rsyslogNew rule not loadedsystemctl restart rsyslog after every config change
Not creating /var/log/journal/Journal stays in memory despite configDirectory must exist for persistence to activate

Task 9: Packages

MistakeWhat goes wrongFix
Misspelling the group name in dnf groupinstallPackage group not foundUse dnf group list to find the exact name first
Not running dnf repolist to verify the repoPackages install from wrong sourceAlways verify repo is visible before installing

Task 10: autofs

MistakeWhat goes wrongFix
Using the wrong map file path in masterautofs can’t find the mapPath in master entry must exactly match the map file path
Not using & in the wildcard mapAll users mount the same export& is replaced by the directory name that was accessed
Testing with ls /mnt/devhomes/This doesn’t trigger the mountAccess a specific subdirectory: ls /mnt/devhomes/dev1

Task 11: Hostname and NTP

MistakeWhat goes wrongFix
Using hostname devserver1 instead of hostnamectlChange is not persistent, lost on rebootAlways use hostnamectl set-hostname
Forgetting to update /etc/hosts with the new hostnameSome services can’t resolve the hostnameAdd 127.0.0.1 devserver1.lab.example.com to /etc/hosts

General Mistakes That Fail the Exam

MistakeImpact
Not rebooting to verify persistencePass tasks manually, fail the reboot check
Setting ACLs on the wrong directoryWrong directory secured, target directory open
Creating sudoers file without validating syntaxBreaks sudo for all users on the system
Forgetting chmod +x on scriptsScript exists but cannot be executed
Not adding yourself to a restricted group before locking downLock yourself out of SSH or sudo
next post
RHCSA EX200 Project 3: Log Management Server