Build a Multi-User Development Environment
An exam-level RHEL 9 capstone you attempt first, then a deep-dive walkthrough.
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.
/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.Task 1: Create users and groups
Create the following groups and users:
- Group
developers(GID 7000) - Group
ops(GID 7001) - Group
contractors(GID 7002) - User
dev1(UID 7001, primary group:developers, shell:/bin/bash) - User
dev2(UID 7002, primary group:developers, shell:/bin/bash) - User
ops1(UID 7003, primary group:ops, shell:/bin/bash) - User
contractor1(UID 7004, primary group:contractors, supplementary group:developers, shell:/bin/bash)
Set the password Dev@12345! for all four users.
Task 2: Configure password policies
Using chage, configure the following for dev1 and dev2:
- Password expires every 90 days
- Warning issued 10 days before expiry
- Account locks 5 days after password expires
Configure /etc/security/pwquality.conf to enforce system-wide:
- Minimum password length: 12 characters
- At least 1 uppercase letter
- At least 1 digit
- At least 1 special character
Task 3: Set up shared project storage
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:
/projects/shared: accessible by bothdevelopersandops(use ACLs)/projects/private: accessible only bydevelopers
Task 4: Configure ACLs on shared storage
On /projects/shared:
- Give group
developersread/write/execute - Give group
opsread/execute only (no write) - Set default ACLs so new files inherit the same permissions
On /projects/private:
- Give group
developersread/write/execute - Give group
opsno access - Verify with
getfacl
Task 5: Configure sudo access
Create the file /etc/sudoers.d/developers so that:
- All members of
developerscan run any command as root without a password - All members of
opscan run only/bin/systemctland/usr/bin/journalctlas root, with password required
Use visudo -f to validate the file. Do not edit /etc/sudoers directly.
Task 6: Write a user activity report script
Create an executable script at /usr/local/bin/user_report.sh that:
- Loops through all users with UID >= 1000 and < 65534
- For each user prints: username, UID, primary group, home directory (exists or MISSING), and last login time
- Outputs a summary line at the end:
Total regular users: N - Saves the report to
/var/log/user_report.txtwith a timestamp header - Exits with code 0 on success, code 1 if the report file cannot be written
Task 7: Schedule the report
Schedule /usr/local/bin/user_report.sh to run:
- Every Monday at 6:00 AM via a root crontab entry
- 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
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
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
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
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
Reboot the system and confirm every configuration survived. Run through the verification checklist in the Outcomes section.
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:
sudo whoami # returns: root (no password prompt)
Expected behavior, ops1 can only run systemctl:
sudo systemctl status sshd # works, password required sudo useradd testuser # fails: not in sudoers for this command
Report Script (Tasks 6, 7)
Manual test:
/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:
crontab -l | grep user_report # 0 6 * * 1 /usr/local/bin/user_report.sh
Verify systemd service:
systemctl is-enabled user-report.service # enabled
Logging (Task 8)
Verify auth events are logged:
su - dev1 -c "sudo whoami" grep "dev1" /var/log/auth_audit.log # Should show sudo session lines
Verify journal persistence:
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:
gcc --version # gcc (GCC) 11.x.x git --version # git version 2.x.x python3 --version # Python 3.x.x
Autofs (Task 10)
ls /mnt/devhomes/dev1 # Lists dev1's NFS home directory contents systemctl is-active autofs # active
Hostname and NTP (Task 11)
hostname # devserver1.lab.example.com timedatectl # Time zone: America/Chicago # System clock synchronized: yes chronyc sources # ^* time.cloudflare.com ...
Task 1: Create users and groups
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
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:
getent group developers ops contractors
Step 1.2: Create the users
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:
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
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).
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
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
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:
chage -l dev1
Step 2.2: Set system-wide complexity
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
su - dev1 -c passwd # try a weak password like "abc" — it should be rejected
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
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
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
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
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:
ls -ld /projects /projects/shared /projects/private
df -h /projects shows ~1 G mounted, and ls -ld /projects shows drwxrws--- (the s confirms setGID).Task 4: Configure ACLs on shared storage
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
# 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
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
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::---
getfacl /projects/shared shows group:ops:r-x and the default: entries; /projects/private shows group:ops:---.Task 5: Configure sudo access
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
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
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
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
dev1 gets root with no prompt; ops1 can run systemctl but is refused useradd.Task 6: Write a user activity report script
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
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.shTwo 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
/usr/local/bin/user_report.sh cat /var/log/user_report.txt
dev1 through contractor1 with UID, group, home status, and last login, and ends with Total regular users: N.Task 7: Schedule the report
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
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:
crontab -l | grep user_report
Step 7.2: Create the systemd service
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).
crontab -l shows the Monday entry, and systemctl is-enabled user-report.service returns enabled.Task 8: Configure system logging and journal
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
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
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
su - dev1 -c 'sudo whoami' grep dev1 /var/log/auth_audit.log
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
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
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
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
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
gcc --version git --version python3 --version
Task 10: Configure autofs for developer home directories
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
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
ls /mnt/devhomes/dev1
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.systemctl is-active autofs returns active.Task 11: Set the hostname and configure NTP
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
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
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.
hostname returns devserver1.lab.example.com, and timedatectl shows America/Chicago with the clock synchronized.Task 12: Reboot and verify everything
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.
reboot
After it comes back, walk the checklist one line at a time:
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
| Symptom | Likely cause | Fix |
|---|---|---|
/projects not mounted | Bad UUID or fstab typo | Boot to emergency mode, fix /etc/fstab, reboot |
| ACLs missing | Set on the wrong path, or filesystem mounted without ACL support | Re-run the setfacl commands on /projects/shared and /projects/private |
dev1 sudo asks for a password | sudoers file wrong perms or syntax | chmod 440 and visudo -c -f /etc/sudoers.d/developers |
ops1 can run any command | ops command list too broad or wrong group | Check the %ops line and id ops1 |
| Report not generated at boot | Service not enabled or daemon-reload skipped | systemctl daemon-reload && systemctl enable —now user-report.service |
Auth events not in auth_audit.log | rsyslog not restarted | systemctl restart rsyslog |
| autofs not mounting | Map path mismatch or missing & | Check /etc/auto.devhomes and the master map; access /mnt/devhomes/<user> |
| Hostname reverted | Used hostname instead of hostnamectl | hostnamectl set-hostname devserver1.lab.example.com |
Task 1: Users and groups
| Mistake | What goes wrong | Fix |
|---|---|---|
| Creating users before groups | useradd fails, group doesn’t exist yet | Always create groups first |
Forgetting -G developers for contractor1 | User is not in the supplementary group | Use -G for supplementary groups, -g for primary |
Using passwd interactively instead of —stdin | Can’t script it, slow in exam | Use echo 'password' \</td><td>passwd --stdin username |
| Setting wrong GID | Group has wrong ID, ACLs may not work correctly | Double check with getent group groupname |
Task 2: Password policies
| Mistake | What goes wrong | Fix |
|---|---|---|
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 out | Policy not enforced | Remove the #, commented lines are ignored |
| Not testing the policy | You assume it works | Try setting a weak password and verify it’s rejected |
Task 3: LVM and storage
| Mistake | What goes wrong | Fix |
|---|---|---|
Forgetting mount -a after editing fstab | Mount not active until reboot | Run mount -a and check for errors immediately |
Setting chmod 770 instead of 2770 | setGID bit not set, group not inherited | Use 2770, the 2 sets the setGID bit |
| Not setting group ownership before chmod | setGID applies to wrong group | chown root:developers /projects before chmod |
Task 4: ACLs
| Mistake | What goes wrong | Fix |
|---|---|---|
Setting ACLs but forgetting default ACLs (-d) | New files don’t inherit permissions | Use setfacl -d -m for default ACLs |
Not setting o::--- | Others still have access | Explicitly remove other access: setfacl -m o::--- |
| Confusing group ACL with standard group permissions | One overrides the other unexpectedly | Use getfacl to verify the effective permissions |
Task 5: Sudo
| Mistake | What goes wrong | Fix |
|---|---|---|
Editing /etc/sudoers directly instead of a file in /etc/sudoers.d/ | Risk of breaking sudo entirely if syntax error | Always use /etc/sudoers.d/ drop-in files |
Forgetting chmod 440 on the sudoers file | sudo ignores world-readable sudoers files | Set 440, sudo requires this exact permission |
Not running visudo -c -f to validate | Syntax errors silently break sudo | Always validate before relying on the config |
Using username instead of %groupname | Only that one user gets access, not the group | Prefix group names with % in sudoers |
Task 6: Shell script
| Mistake | What goes wrong | Fix |
|---|---|---|
Parsing /etc/passwd with cut instead of IFS=: read | Fragile, breaks with unusual entries | Use while IFS=: read -r … for reliable field splitting |
| Hardcoding the user list | Script breaks when users are added/removed | Always read from /etc/passwd dynamically |
| Not checking if the report file is writable | Script runs but saves nothing | Test the write at the start and exit with code 1 if it fails |
Task 7: Scheduling
| Mistake | What goes wrong | Fix |
|---|---|---|
| Wrong cron day-of-week for Monday | Job runs on wrong day, 1 = Monday in cron | Day field: 0=Sun, 1=Mon, 2=Tue … 7=Sun |
Enabling service without daemon-reload first | systemd doesn’t know about the unit | Always daemon-reload before enable or start |
Using WantedBy=default.target | May not work in multi-user environments | Use WantedBy=multi-user.target for server services |
Task 8: Logging
| Mistake | What goes wrong | Fix |
|---|---|---|
Writing rsyslog rule in /etc/rsyslog.conf directly | Works but clutters the main config | Use drop-in files in /etc/rsyslog.d/ |
| Forgetting to restart rsyslog | New rule not loaded | systemctl restart rsyslog after every config change |
Not creating /var/log/journal/ | Journal stays in memory despite config | Directory must exist for persistence to activate |
Task 9: Packages
| Mistake | What goes wrong | Fix |
|---|---|---|
Misspelling the group name in dnf groupinstall | Package group not found | Use dnf group list to find the exact name first |
Not running dnf repolist to verify the repo | Packages install from wrong source | Always verify repo is visible before installing |
Task 10: autofs
| Mistake | What goes wrong | Fix |
|---|---|---|
| Using the wrong map file path in master | autofs can’t find the map | Path in master entry must exactly match the map file path |
Not using & in the wildcard map | All users mount the same export | & is replaced by the directory name that was accessed |
Testing with ls /mnt/devhomes/ | This doesn’t trigger the mount | Access a specific subdirectory: ls /mnt/devhomes/dev1 |
Task 11: Hostname and NTP
| Mistake | What goes wrong | Fix |
|---|---|---|
Using hostname devserver1 instead of hostnamectl | Change is not persistent, lost on reboot | Always use hostnamectl set-hostname |
Forgetting to update /etc/hosts with the new hostname | Some services can’t resolve the hostname | Add 127.0.0.1 devserver1.lab.example.com to /etc/hosts |
General Mistakes That Fail the Exam
| Mistake | Impact |
|---|---|
| Not rebooting to verify persistence | Pass tasks manually, fail the reboot check |
| Setting ACLs on the wrong directory | Wrong directory secured, target directory open |
| Creating sudoers file without validating syntax | Breaks sudo for all users on the system |
Forgetting chmod +x on scripts | Script exists but cannot be executed |
| Not adding yourself to a restricted group before locking down | Lock yourself out of SSH or sudo |