Deploy a Secure Web Server with Persistent Storage
An exam-level RHEL 9 capstone you attempt first, then a deep-dive walkthrough.
Scenario: You are a sysadmin setting up webserver1.lab.example.com as a production-ready internal web server. It needs dedicated LVM storage, a service account, hardened SSH, an automated backup script, nginx serving a custom page, a Podman monitoring container that starts at boot, and NTP configured correctly.
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 1, and internet access. No Git repository is involved. dnf pulls nginx and autofs from your software repositories (a Red Hat subscription, or Rocky/AlmaLinux 9), and podman pull fetches one public image (ubi9/ubi-minimal, no login). Task 11 points autofs at a fictional NFS host, so ls /mnt/content stays empty unless you stand up a matching export. That is expected.Task 1: Partition and mount web storage
Create a 2 GiB LVM Logical Volume named lv_webdata inside a new Volume Group vg_web using /dev/sdb. Format it as XFS. Mount it persistently at /var/www/html using its UUID in /etc/fstab.
Task 2: Create the web service account
Create the group webteam with GID 6000. Create user webadmin with UID 6001, primary group webteam, and no login shell (/sbin/nologin). Set ownership of /var/www/html to root:webteam. Set the setGID bit so all new files created inside inherit the webteam group. Permissions should be 2775.
Task 3: Deploy nginx and configure the firewall
Install the nginx package. Enable and start the service. Configure firewalld to permanently allow HTTP and HTTPS traffic. Create /var/www/html/index.html with the content:
Deployed by RHCSA Student
Task 4: Set the correct SELinux context
Apply the SELinux file context httpd_sys_content_t to /var/www/html and all files inside it. Make the context persistent using semanage fcontext and apply it with restorecon. Verify nginx can serve the page with curl localhost.
Task 5: Harden SSH access
Edit /etc/ssh/sshd_config to:
- Disable root login (
PermitRootLogin no) - Disable password authentication (
PasswordAuthentication no) - Allow only users in the
webteamgroup (AllowGroups webteam)
Add your current user to the webteam group before restarting sshd to avoid locking yourself out. Restart the sshd service.
Task 6: Write an automated backup script
Create an executable script at /usr/local/bin/web_backup.sh that:
- Takes no arguments
- Creates a
.tar.gzarchive of/var/www/html - Names the archive
web_YYYYMMDD.tar.gzusing the current date - Saves it to
/backups/(create the directory if it does not exist) - Prints
Backup created: /backups/web_YYYYMMDD.tar.gzon success - Prints
ERROR: Backup failedand exits with code 1 on failure
Task 7: Schedule the backup with cron and a systemd timer
- Add a root crontab entry to run
/usr/local/bin/web_backup.shevery night at 2:00 AM - Create a systemd service unit at
/etc/systemd/system/web-backup.serviceand a timer unit at/etc/systemd/system/web-backup.timerthat runs the backup every 6 hours. Enable and start the timer.
Task 8: Configure NTP and timezone
Set the system timezone to America/New_York. Configure chronyd to use time.cloudflare.com iburst as its NTP source. Remove or comment out any existing pool or server lines. Restart chronyd and verify synchronization is active.
Task 9: Run a Podman monitoring container
As root, pull registry.access.redhat.com/ubi9/ubi-minimal:latest. Run a detached container named webmon that:
- Mounts
/var/log/nginxon the host to/logsinside the container (use:Zfor SELinux) - Runs
sleep infinity
Generate a systemd unit file with podman generate systemd, place it in /etc/systemd/system/, enable it, and confirm it starts after a reboot.
Task 10: Make the journal persistent and set boot target
Configure /etc/systemd/journald.conf with Storage=persistent and SystemMaxUse=300M. Create /var/log/journal/ to activate persistence. Set the default systemd boot target to multi-user.target. Restart journald.
Task 11: Configure autofs for shared content
Configure autofs to automatically mount fileserver.lab.example.com:/exports/content at /mnt/content when accessed. Use an indirect map. Enable and start the autofs service.
Task 12: Reboot and verify everything
Reboot the system and confirm every configuration survived. Run through the verification checklist in the Outcomes section below.
Storage (Tasks 1)
The LVM logical volume lv_webdata should exist in volume group vg_web and be mounted at /var/www/html.
Expected output of df -h /var/www/html:
Filesystem Size Used Avail Use% Mounted on /dev/mapper/vg_web-lv_webdata 2.0G 47M 1.9G 3% /var/www/html
Expected output of lvs:
LV VG Attr LSize lv_webdata vg_web -wi-ao---- 2.00g
Expected /etc/fstab entry:
UUID=<uuid> /var/www/html xfs defaults 0 0
Users and Permissions (Task 2)
Group webteam exists with GID 6000. User webadmin exists with UID 6001 and cannot log in.
Expected output of id webadmin:
uid=6001(webadmin) gid=6000(webteam) groups=6000(webteam)
Expected output of ls -ld /var/www/html:
drwxrwsr-x. 2 root webteam 6 Apr 20 2026 /var/www/html
The s in the group execute position confirms the setGID bit is set.
Web Server (Tasks 3, 4)
nginx is running, enabled, and serving the custom page. Firewall allows HTTP/HTTPS. SELinux context is correct.
Expected output of systemctl is-active nginx:
active
Expected output of curl localhost:
Deployed by RHCSA Student
Expected output of firewall-cmd —list-services:
cockpit dhcpd http https ssh
Expected output of ls -Z /var/www/html/index.html:
unconfined_u:object_r:httpd_sys_content_t:s0 /var/www/html/index.html
SSH Hardening (Task 5)
Root login and password auth are disabled. Only webteam members can connect.
Expected lines in /etc/ssh/sshd_config:
PermitRootLogin no PasswordAuthentication no AllowGroups webteam
Verify sshd is running:
systemctl is-active sshd active
Backup Script (Tasks 6, 7)
The script exists, is executable, and runs correctly. The cron job and systemd timer are both active.
Expected output of ls -lh /usr/local/bin/web_backup.sh:
-rwxr-xr-x. 1 root root 312 Apr 20 2026 /usr/local/bin/web_backup.sh
Manual test, run the script:
/usr/local/bin/web_backup.sh # Expected output: Backup created: /backups/web_20260420.tar.gz
Expected output of crontab -l:
0 2 * * * /usr/local/bin/web_backup.sh
Expected output of systemctl list-timers | grep web:
web-backup.timer ... 6h web-backup.service
NTP (Task 8)
chronyd is running and synchronized. Timezone is correct.
Expected output of timedatectl:
Local time: ...
Universal time: ...
RTC time: ...
Time zone: America/New_York (EDT, -0400)
System clock synchronized: yes
NTP service: activeExpected output of chronyc sources:
MS Name/IP address Stratum Poll Reach LastRx Last sample ^* time.cloudflare.com 3 6 377 12 +0.5ms
Container (Task 9)
The webmon container is running and managed by systemd.
Expected output of podman ps:
CONTAINER ID IMAGE COMMAND STATUS abc123 ubi9/ubi-minimal:latest sleep infinity Up 2 minutes
Expected output of systemctl is-active container-webmon:
active
Verify volume mount inside container:
podman exec webmon ls /logs # Should show nginx log files
Journal and Boot Target (Task 10)
Journal is persistent, max size is 300M, and boot target is multi-user.
Expected output of systemctl get-default:
multi-user.target
Expected output of journalctl —disk-usage:
Archived and active journals take up ... (max allowed 300.0M)
Verify journal directory exists:
ls /var/log/journal/ # Should show a directory with journal files
Autofs (Task 11)
Accessing /mnt/content triggers the NFS mount automatically.
Test:
ls /mnt/content # Should list files from the NFS export
Expected output of systemctl is-active autofs:
active
Task 1: Partition and Mount Web Storage
Traditional disk partitions are fixed at creation. Size /var/www/html at 2 GB today and need 20 GB next quarter, and a raw partition forces a backup-repartition-restore cycle. LVM (Logical Volume Manager) inserts a layer of indirection between the physical disk and the filesystem that makes storage elastic, which is exactly why the exam leans on it.
The stack has three tiers. A physical volume (PV) is a disk or partition handed to LVM with pvcreate. One or more PVs are pooled into a volume group (VG) with vgcreate, think of the VG as a single allocatable capacity pool. From that pool you carve logical volumes (LVs) with lvcreate; an LV behaves like a partition but can be grown, and in some cases shrunk or moved, while mounted. The kernel exposes each LV through device-mapper at /dev/mapper/vg_web-lv_webdata, with a convenience symlink at /dev/vg_web/lv_webdata. Capacity is tracked in fixed-size physical extents (4 MiB by default), which is why volumes always round to extent boundaries.
XFS is the RHEL 9 default filesystem, a high-performance journaling filesystem built for large volumes and heavy parallel I/O. One exam-critical quirk: XFS can be grown online with xfs_growfs but can never be shrunk. If a task ever says “reduce the filesystem,” that is ext4 territory, not XFS.
Persistence lives in /etc/fstab, which systemd reads at every boot to assemble the mount tree. The golden rule is to mount by UUID, never by device path: kernel names like /dev/sdb are assigned in device-discovery order and can shift when disks are added or reordered, whereas a filesystem UUID is written into the superblock and is stable for the life of the filesystem. The six fstab fields are device, mount point, type, options, dump, and pass. defaults 0 0 means standard options, no dump-based backup, and no boot-time fsck, XFS performs its consistency checks online, so the pass field is 0.
The cost of an fstab mistake is severe and is a classic way to fail the exam: a single bad entry can drop the system into emergency mode on the next boot. That is why you always run mount -a immediately after editing, it mounts everything declared in fstab and surfaces syntax or UUID errors now, while they are trivial to fix, rather than at the reboot when they are not.
Goal: Create a dedicated LVM volume and mount it at /var/www/html.
Step 1.1: Verify the disk is available
lsblk
Look for /dev/sdb with no partitions under it. If sdb1, sdb2 etc. already appear, use a different disk or wipe it first with wipefs -a /dev/sdb.
Step 1.2: Create the Physical Volume
pvcreate /dev/sdb
Initializes /dev/sdb as an LVM physical volume.
Expected output:
Physical volume "/dev/sdb" successfully created.
Step 1.3: Create the Volume Group
vgcreate vg_web /dev/sdb
Expected output:
Volume group "vg_web" successfully created
Verify:
vgs
vg_web should appear with available free space.
Step 1.4: Create the Logical Volume
lvcreate -L 2G -n lv_webdata vg_web
-L 2G sets the size. -n lv_webdata sets the name.
Expected output:
Logical volume "lv_webdata" created.
Verify:
lvs
Step 1.5: Format as XFS
mkfs.xfs /dev/vg_web/lv_webdata
XFS is the default RHEL 9 filesystem and what the exam expects unless told otherwise.
Step 1.6: Create the mount point
mkdir -p /var/www/html
Step 1.7: Get the UUID
blkid -s UUID -o value /dev/vg_web/lv_webdata
Copy this value carefully, you need it in the next step. UUIDs are permanent; device names like /dev/sdb can change between reboots.
Example output:
a1b2c3d4-e5f6-7890-abcd-ef1234567890
Step 1.8: Add the entry to /etc/fstab
vim /etc/fstab
Add this line at the bottom, replacing <uuid> with your actual UUID:
UUID=<uuid> /var/www/html xfs defaults 0 0
Save and quit: Esc → :wq
Field breakdown:
UUID=…: which filesystem to mount/var/www/html: where to mount itxfs: filesystem typedefaults: standard mount options0 0: no dump, no fsck on boot
Step 1.9: Mount and verify
mount -a df -h /var/www/html
Expected output:
Filesystem Size Used Avail Use% Mounted on /dev/mapper/vg_web-lv_webdata 2.0G 47M 1.9G 3% /var/www/html
df -h /var/www/html still shows the root filesystem, the mount failed. Fix fstab before continuing.Task 2: Create the Web Service Account
Linux identity is numeric. Every user has a UID and every group a GID; the names you see are a convenience layer resolved through /etc/passwd and /etc/group. By convention, UIDs and GIDs below 1000 are reserved for the system and services, while regular human accounts start at 1000. Choosing 6000/6001 here deliberately places this service identity in the “service” range, well clear of real users.
A user has exactly one primary group (the GID recorded in /etc/passwd, applied to files they create) and any number of supplementary groups. useradd -g webteam sets the primary group explicitly; omit it and useradd helpfully creates a brand-new private group named after the user and ignores your intended GID scheme. The login shell matters too: /sbin/nologin gives the account no interactive shell, so it can own files and run services but cannot be logged into, the correct posture for a service account that should never have a human at a prompt.
Standard permissions are the familiar three triads, owner, group, other, each carrying read (4), write (2), and execute (1). On top of those sit three special bits, expressed as a leading fourth octal digit: setuid (4), setgid (2), and the sticky bit (1). The task uses setgid on a directory, which is the conceptual heart of this exercise. A setgid directory forces every file and subdirectory created inside it to inherit the directory’s group rather than the creating user’s primary group.
That is why 2775 is precise, not arbitrary. The leading 2 is setgid, guaranteeing new content stays owned by webteam no matter who writes it. 7 for owner and 7 for group give the team full read/write/execute. The final 5, read and execute for others, is what lets the nginx worker process, which runs as an unprivileged user outside webteam, traverse the directory and read the files it serves. Drop that to 2770 and nginx returns 403s. Set ownership with chown before chmod, because the setgid bit binds to whatever group owns the directory at the moment it is set.
Goal: Create a group and user for the web service with correct directory ownership and permissions.
Step 2.1: Create the webteam group
groupadd -g 6000 webteam
Verify:
getent group webteam
Expected: webteam:x:6000:
Step 2.2: Create the webadmin user
useradd -u 6001 -g webteam -s /sbin/nologin webadmin
-u 6001 sets the UID. -g webteam sets the primary group. -s /sbin/nologin prevents interactive login.
Verify:
id webadmin
Expected: uid=6001(webadmin) gid=6000(webteam) groups=6000(webteam)
Step 2.3: Set directory ownership
chown root:webteam /var/www/html
Step 2.4: Set permissions with setGID
chmod 2775 /var/www/html
Breaking down 2775:
2: setGID: new files inherit thewebteamgroup automatically7: owner (root): read, write, execute7: group (webteam): read, write, execute5: others: read, execute (nginx needs this to serve files)
Verify:
ls -ld /var/www/html
Expected: drwxrwsr-x. 2 root webteam …, the s in position 6 confirms setGID is set.
Task 3: Deploy nginx and Configure the Firewall
This task chains three subsystems: package management, service management, and the firewall. Packages come from dnf, RHEL’s dependency-resolving front end to the RPM database; dnf install pulls nginx and everything it needs from the configured repositories. Service lifecycle is systemd’s job: systemctl start runs a unit now, systemctl enable wires it into the boot sequence, and —now does both in one command. On the exam, “enabled and started” almost always means enable —now, forgetting enable is how a service that worked in your session vanishes after the reboot check.
One detail the steps below quietly depend on: nginx’s default document root on RHEL is /usr/share/nginx/html, not /var/www/html, the latter is Apache’s historical convention. For curl localhost to return your page, nginx must actually be pointed at /var/www/html: set root /var/www/html; in the server block of /etc/nginx/nginx.conf (then nginx -t && systemctl restart nginx). Skip that and nginx cheerfully serves its default welcome page, and the SELinux context you set in Task 4 will look like the culprit when it is innocent. This is one of the most common “it should work but doesn’t” traps in the whole project.
firewalld is RHEL’s default firewall manager, and it is zone-based: every interface belongs to a zone (usually public), and each zone has a rule set. Rules exist in two layers, a runtime configuration that is live but volatile, and a permanent configuration written to disk. —permanent writes the rule but does not apply it live; —reload re-reads the permanent config into runtime. The reliable pattern is therefore —permanent then —reload; using neither means the rule evaporates at reboot, the single most common firewall mistake on the exam. The named services http and https are simply predefined bundles mapping to TCP 80 and 443.
Goal: Install nginx, start it, open the firewall, and create the web page.
Step 3.1: Install nginx
dnf install -y nginx
Step 3.2: Enable and start nginx
systemctl enable --now nginx
enable makes nginx start at boot. —now starts it immediately.
Verify:
systemctl is-active nginx
Expected: active
Step 3.3: Open the firewall
firewall-cmd --permanent --add-service=http firewall-cmd --permanent --add-service=https firewall-cmd --reload
Without —permanent the rules disappear on reboot. Without —reload permanent rules don’t activate immediately.
Verify:
firewall-cmd --list-services
http and https should appear in the list.
Step 3.4: Create the web page
echo 'Deployed by RHCSA Student' > /var/www/html/index.html
Do not run curl yet, SELinux context is not set. Do that after Task 4.
Task 4: Set the Correct SELinux Context
SELinux is mandatory access control layered on top of ordinary Unix permissions. Even when the file mode says “allowed,” SELinux can still deny the access if the policy disagrees, which is precisely why a perfectly readable file can still produce a 403 from nginx. The mechanism is type enforcement: every process runs in a domain and every file carries a type, and the policy defines which domains may touch which types.
A context has four colon-separated fields, SELinux user, role, type, and level, but for file labeling the type is the field that decides everything. Web content must carry httpd_sys_content_t, because the web server domain (httpd_t, which nginx shares on RHEL) is permitted to read exactly that type. The default policy already labels /usr/share/nginx/html and /var/www correctly; the moment you serve content from a freshly mounted LVM volume, that new filesystem has no such labeling and you must establish it yourself.
There are two ways to set a context, and choosing correctly is the exam point. chcon changes the label on disk immediately but does not record it in policy, so the next filesystem relabel, or a stray restorecon, silently reverts it. semanage fcontext -a writes a rule into the local policy describing what the label should be, making the change durable; restorecon then reads that policy and applies the correct label to files on disk. The persistent, correct workflow is always semanage fcontext followed by restorecon.
The regular-expression suffix (/.*)? is essential and frequently forgotten. It means “this directory and everything beneath it.” Without it the rule labels only the directory node and leaves the files inside unlabeled, which produces the maddening symptom of a correctly contexted directory that still cannot be served. Finally, verify with getenforce: if SELinux is Permissive, denials are logged but not enforced, so a working curl proves nothing, the misconfiguration is waiting to bite the instant the system returns to Enforcing.
Goal: Label the web directory so nginx can serve files.
Step 4.1: Add the persistent fcontext rule
semanage fcontext -a -t httpd_sys_content_t '/var/www/html(/.*)?'
The (/.*)? pattern matches the directory itself AND everything inside it. Without it only the directory gets the context, not the files.
Step 4.2: Apply the context to existing files
restorecon -Rv /var/www/html
-R is recursive. -v shows what changed.
Expected output:
Relabeled /var/www/html/index.html from ... to unconfined_u:object_r:httpd_sys_content_t:s0
Step 4.3: Verify nginx serves the page
curl localhost
Expected output:
Deployed by RHCSA Student
If you get 403 Forbidden, run ls -Z /var/www/html and verify httpd_sys_content_t appears on the files.
Step 4.4: Verify the SELinux context
ls -Z /var/www/html/index.html
Expected: unconfined_u:object_r:httpd_sys_content_t:s0 /var/www/html/index.html
curl localhost must return Deployed by RHCSA Student before moving on.Task 5: Harden SSH Access
Hardening SSH is where a careless command turns into a locked-out host, so the order of operations is the lesson. Three directives in /etc/ssh/sshd_config do the work. PermitRootLogin no blocks direct root authentication, forcing administrators to log in as a normal user and escalate, which preserves an audit trail and removes the single most attacked username on the internet. PasswordAuthentication no disables password logins entirely, defeating brute-force and credential-stuffing attacks by requiring cryptographic keys. AllowGroups webteam is an allow-list: only members of webteam may authenticate at all, regardless of other settings.
The danger is that AllowGroups takes effect the instant sshd restarts. If your own account is not already in webteam, the restart severs every future SSH session, including yours. That is why the very first step adds your user to the group, and why disabling password auth without a working key pair in place is the express route to a host you can only reach from the physical console. Key-based auth relies on a private key on your client and its matching public key in ~/.ssh/authorized_keys on the server; the file must be mode 600 (and ~/.ssh mode 700), because sshd refuses overly permissive key files as a security measure.
The professional safeguard is sshd -t, which parses the configuration and validates syntax without touching the running daemon. Run it before every restart: a typo caught by sshd -t costs you ten seconds, whereas the same typo discovered after a restart can leave sshd refusing to start and you staring at a console login at the worst possible time.
Goal: Restrict SSH so only webteam members can connect with no password or root login.
Step 5.1: Add your user to webteam FIRST
usermod -aG webteam $(whoami)
You are about to restrict SSH to AllowGroups webteam. If your user is not in webteam before restarting sshd you will be locked out.
Verify:
id $(whoami)
webteam must appear in the groups list before proceeding.
Step 5.2: Confirm key-based SSH works
ssh-keygen -t rsa -N '' -f ~/.ssh/id_rsa cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys
If you already have keys, skip key generation.
Step 5.3: Edit sshd_config
vim /etc/ssh/sshd_config
Find and set these three lines:
PermitRootLogin no PasswordAuthentication no AllowGroups webteam
Use /PermitRootLogin to search. Remove # if commented. Save with :wq.
Step 5.4: Test the config before restarting
sshd -t
If this returns no output the config is valid. If you see an error fix it before proceeding.
Step 5.5: Restart sshd
systemctl restart sshd systemctl is-active sshd
Expected: active
Task 6: Write an Automated Backup Script
This task is a compact lesson in defensive shell scripting. The shebang #!/bin/bash on line one tells the kernel which interpreter to use; without it the script may run under whatever shell happens to invoke it, with subtly different behavior. Command substitution, $(date +%Y%m%d), captures the output of a command into a variable, here producing a dated filename like web_20260420.tar.gz so each run is uniquely named rather than overwriting the last.
The script’s correctness hinges on checking the exit status. Every command returns a status code: 0 for success, non-zero for failure. Wrapping tar in if tar …; then branches on that status, so the script reports “Backup created” only when the archive truly succeeded and exits 1 on failure, a non-zero exit is what lets schedulers and monitoring detect that the job broke. tar -czf means create, gzip-compress, and write to the named file; the 2>/dev/null discards tar’s harmless “removing leading /” notice on stderr.
A subtle but important detail is the quoted heredoc, <<‘EOF’. Quoting the delimiter tells bash to write the body verbatim, so $DATE and $(date …) land in the file unexpanded and are evaluated each time the script runs. An unquoted <<EOF would expand those variables once, at file-creation time, freezing the date forever, a classic, hard-to-spot bug. The closing rule of the task is operational, not syntactic: always run a new script by hand before scheduling it, because cron and systemd run it silently and a broken backup discovered months later is no backup at all.
Goal: Create a script that backs up /var/www/html to a timestamped archive.
Step 6.1: Create the script
cat > /usr/local/bin/web_backup.sh <<'EOF'
#!/bin/bash
DEST="/backups"
DATE=$(date +%Y%m%d)
ARCHIVE="${DEST}/web_${DATE}.tar.gz"
mkdir -p "$DEST"
if tar -czf "$ARCHIVE" /var/www/html 2>/dev/null; then
echo "Backup created: $ARCHIVE"
else
echo "ERROR: Backup failed"
exit 1
fi
EOFScript breakdown:
#!/bin/bash: always required as the first lineDATE=$(date +%Y%m%d): command substitution for today’s datemkdir -p “$DEST”: creates the backup dir if it doesn’t existif tar …; then: checks if tar succeeded before printing success
Step 6.2: Make it executable
chmod +x /usr/local/bin/web_backup.sh
Step 6.3: Test it manually
/usr/local/bin/web_backup.sh
Expected output:
Backup created: /backups/web_20260420.tar.gz
Verify the archive:
ls -lh /backups/ tar -tzf /backups/web_20260420.tar.gz
Always test scripts manually before scheduling them, cron runs silently.
Task 7: Schedule the Backup
Scheduling the same job two ways is deliberate: the exam wants proof you can drive both the classic and the modern scheduler. cron is the traditional time-based job runner. A crontab line has five time fields, minute, hour, day-of-month, month, day-of-week, followed by the command. 0 2 * * * reads as “minute 0 of hour 2, every day, every month, every weekday,” i.e. 2:00 AM nightly. The most common error is field-order confusion; 2 * * * * would instead run at two minutes past every hour.
The systemd approach splits the work across two units. A service unit defines what to run; here Type=oneshot is correct because the script runs to completion and exits, as opposed to Type=simple for long-lived daemons. A timer unit defines when, and is bound to the service it triggers. This timer uses monotonic triggers: OnBootSec=10min fires ten minutes after boot, and OnUnitActiveSec=6h fires every six hours after the previous activation. (The alternative, OnCalendar=, expresses wall-clock schedules like cron.)
Two operational rules trip people up. First, after writing or editing any unit file you must run systemctl daemon-reload so systemd re-reads its configuration, skip it and systemd behaves as though the unit does not exist. Second, you enable and start the timer, not the service: enabling the service would run the backup once at enable time and never again, while enabling the timer is what installs the recurring schedule. Confirm the result with systemctl list-timers, which shows the next scheduled firing.
Goal: Run the backup automatically at 2 AM daily and every 6 hours via systemd.
Step 7.1: Add the cron job
crontab -e
Add:
0 2 * * * /usr/local/bin/web_backup.sh
Cron field order: minute hour day month weekday command
0 2= 2:00 AM* * *= every day
Verify:
crontab -l
Step 7.2: Create the systemd service unit
cat > /etc/systemd/system/web-backup.service <<EOF [Unit] Description=Web Content Backup [Service] Type=oneshot ExecStart=/usr/local/bin/web_backup.sh EOF
Type=oneshot is correct for scripts that run and exit. Do not use Type=simple for one-shot tasks.
Step 7.3: Create the systemd timer unit
cat > /etc/systemd/system/web-backup.timer <<EOF [Unit] Description=Run web backup every 6 hours [Timer] OnBootSec=10min OnUnitActiveSec=6h Unit=web-backup.service [Install] WantedBy=timers.target EOF
OnBootSec=10min, first run 10 minutes after boot. OnUnitActiveSec=6h, every 6 hours after that.
Step 7.4: Enable and start the timer
systemctl daemon-reload systemctl enable --now web-backup.timer
Always daemon-reload first. Enable the TIMER, not the service.
Verify:
systemctl list-timers | grep web
Task 8: Configure NTP and Timezone
Accurate time is infrastructure, not housekeeping. Kerberos authentication rejects requests whose clocks skew more than a few minutes, TLS certificate validation depends on the current time, and correlating logs across machines is impossible if their clocks disagree. RHEL 9 keeps time with chrony, whose daemon chronyd disciplines the system clock against network time sources defined in /etc/chrony.conf.
That file distinguishes a pool directive, a DNS name that resolves to many rotating servers, from a server directive naming one specific source. The task replaces the defaults with a single explicit server, so the existing pool (and any stray server) lines must be commented out first; leave them in and chrony averages your intended source against the ones it is still polling, muddying the result. The iburst option tells chrony to send a rapid initial burst of packets on startup so it can lock on in seconds rather than minutes.
Verification is its own skill. chronyc sources lists each source with a status glyph in the first column: ^* marks the currently selected, synchronized source; ^+ an acceptable alternate; and ^? a source not yet reachable. Seeing ^? right after a restart is normal, wait and re-check. Separately, timedatectl shows the timezone and whether NTP synchronization is active; the timezone itself is a property of the system set with timedatectl set-timezone, independent of the clock-sync source.
Step 8.1: Set the timezone
timedatectl set-timezone America/New_York timedatectl | grep "Time zone"
Expected: Time zone: America/New_York (EDT, -0400)
Step 8.2: Remove existing NTP sources
sed -i 's/^pool/#pool/' /etc/chrony.conf sed -i 's/^server/#server/' /etc/chrony.conf
Verify nothing remains uncommented:
grep -E "^pool|^server" /etc/chrony.conf
Should return nothing.
Step 8.3: Add the new NTP server
echo "server time.cloudflare.com iburst" >> /etc/chrony.conf
iburst sends a burst of packets on first contact to synchronize faster.
Step 8.4: Restart and verify
systemctl restart chronyd chronyc sources -v
Look for ^* next to time.cloudflare.com, the asterisk means synchronized. If you see ^? wait 30 seconds and try again.
Task 9: Run a Podman Monitoring Container
Podman is RHEL’s container engine, a drop-in alternative to Docker that runs without a central daemon and supports unprivileged “rootless” containers. Images follow the OCI standard; registry.access.redhat.com/ubi9/ubi-minimal is Red Hat’s Universal Base Image, a small, freely redistributable RHEL-derived image that needs no subscription to pull. podman run -d starts a container detached, and sleep infinity is the idiomatic way to keep an otherwise-idle container alive so it stays available for inspection.
The volume flag -v /var/log/nginx:/logs:Z bind-mounts a host directory into the container, and the trailing :Z is the SELinux-critical part. Containers run confined under the container_t domain, which cannot read arbitrary host files. :Z tells Podman to relabel the host directory with a private container context so the container may access it; a lowercase :z applies a shared label instead. Omit the suffix entirely and SELinux denies the mount’s contents, producing the illusion of a broken volume.
Making the container start at boot means handing its lifecycle to systemd. The walkthrough uses podman generate systemd —new, which emits a unit file describing the container; the —new flag is essential because it makes the unit recreate the container from its definition rather than referencing a one-off container ID that disappears if the container is ever removed. Note for currency: podman generate systemd is deprecated on RHEL 9 in favor of Quadlet, where you describe the container in a .container file under /etc/containers/systemd/ and systemd generates the unit at daemon-reload. The generate-systemd method still works and is what many current exam objectives reference, but expect Quadlet to be the forward path.
Step 9.1: Pull the image
podman pull registry.access.redhat.com/ubi9/ubi-minimal:latest podman images
Step 9.2: Ensure the nginx log directory exists
mkdir -p /var/log/nginx
Step 9.3: Run the container
podman run -d --name webmon \ -v /var/log/nginx:/logs:Z \ registry.access.redhat.com/ubi9/ubi-minimal:latest \ sleep infinity
Flag breakdown:
-d: detached (background)—name webmon: named container for easy reference-v /var/log/nginx:/logs:Z: mounts host dir to container;:Zrelabels for SELinuxsleep infinity: keeps container alive
Verify:
podman ps
Step 9.4: Generate the systemd unit file
podman generate systemd --name webmon --new --files
—new is critical, without it the unit references the container by ID which breaks if the container is ever recreated.
Step 9.5: Install, enable, and start
mv container-webmon.service /etc/systemd/system/ systemctl daemon-reload systemctl enable --now container-webmon.service
Verify:
systemctl is-active container-webmon podman ps
Task 10: Make the Journal Persistent and Set Boot Target
The systemd journal (systemd-journald) is the central, structured log store for the whole system. By default on many installs it is volatile: logs live in /run/log/journal, which is a tmpfs in RAM, so every reboot wipes the history, fine for a throwaway box, useless for diagnosing why a server crashed last night. Switching to persistent storage keeps logs across reboots in /var/log/journal.
The configuration has a deceptively simple trap. Setting Storage=persistent in /etc/systemd/journald.conf is necessary but not sufficient: journald only writes persistently if the directory /var/log/journal actually exists. Create the directory, restart journald, and persistence engages; set the option but skip the directory and the journal silently keeps writing to RAM. SystemMaxUse=300M caps the on-disk journal so logs cannot fill the partition, an important safety valve on production systems.
The boot target is a separate concept that rides along in this task. systemd targets are named groupings of units that represent a system state; multi-user.target is a full multi-user system without a graphical desktop, while graphical.target adds the display manager. The default is a symlink, /etc/systemd/system/default.target, and you set it with systemctl set-default, which is persistent. Do not confuse it with systemctl isolate, which switches the current session’s target immediately but reverts at the next boot; using isolate when the task wants a persistent change is a frequent mistake.
Step 10.1: Configure journald
grep -q "^Storage=" /etc/systemd/journald.conf || echo "Storage=persistent" >> /etc/systemd/journald.conf grep -q "^SystemMaxUse=" /etc/systemd/journald.conf || echo "SystemMaxUse=300M" >> /etc/systemd/journald.conf
Step 10.2: Create the journal directory
mkdir -p /var/log/journal
This is required. Without it journald stays in memory even with Storage=persistent set.
Step 10.3: Restart journald
systemctl restart systemd-journald journalctl --disk-usage ls /var/log/journal/
Step 10.4: Set the default boot target
systemctl set-default multi-user.target systemctl get-default
Expected: multi-user.target
Task 11: Configure autofs
autofs mounts filesystems on demand and unmounts them after a period of inactivity, rather than holding them mounted continuously the way an /etc/fstab entry would. For network shares this is a meaningful advantage: the mount is attempted only when something actually accesses the path, so a temporarily unreachable NFS server cannot hang the boot process or wedge a static mount, and idle shares release automatically.
Configuration is split across two kinds of file. The master map, on RHEL 9, a drop-in under /etc/auto.master.d/, associates a base mount point with a map file: /mnt /etc/auto.content says “keys defined in /etc/auto.content are mounted under /mnt.” The referenced map file then defines the actual mounts. The entry content -rw,sync fileserver…:/exports/content is an indirect map: the key content becomes the subdirectory /mnt/content, created and mounted only when accessed. (A direct map, by contrast, uses an absolute path as the key and a /- placeholder in the master map.)
The behavior that confuses newcomers is that /mnt/content does not visibly exist until something touches it, listing /mnt alone will not show it and will not trigger the mount. You must access the full path, e.g. ls /mnt/content, to fire the automounter. Note that in this lab the NFS server is fictional, so the autofs configuration can be entirely correct while the mount itself stays empty; on the exam you are graded on the configuration, not on a live remote export.
Step 11.1: Install autofs
dnf install -y autofs
Step 11.2: Create the master map entry
echo '/mnt /etc/auto.content' > /etc/auto.master.d/content.autofs
Use /etc/auto.master.d/, the RHEL 9 preferred location for drop-in map files.
Step 11.3: Create the map file
echo 'content -rw,sync fileserver.lab.example.com:/exports/content' > /etc/auto.content
content is the subdirectory under /mnt that triggers the mount. Access /mnt/content to trigger it.
Step 11.4: Enable, start, and test
systemctl enable --now autofs ls /mnt/content
Accessing the path triggers the mount automatically.
Task 12: Reboot and Verify Everything
The final task encodes the single most important habit the whole project is teaching: nothing counts until it survives a reboot. Every preceding task had a persistence dimension, —permanent on firewalld, enable on every service and timer, set-default for the boot target, UUID entries in fstab, semanage for SELinux, a Quadlet or generated unit for the container. A configuration that works only in your live session is a magic trick; the reboot is what separates a trick from a real, durable system.
Reboot deliberately, then walk the verification checklist line by line. Each command confirms one task’s persistence: the volume re-mounted, the service active, the page served, the context intact, the timer armed, the clock synced. If any line returns the wrong result, the fault is almost always a missing persistence step, an un-enabled unit, a runtime-only firewall rule, an isolate where set-default belonged. The troubleshooting table below maps the common post-reboot symptoms to their usual causes so you can repair rather than guess.
reboot
After reboot, run the full checklist:
df -h /var/www/html # LV mounted at 2G id webadmin # uid=6001 gid=6000(webteam) ls -ld /var/www/html # drwxrwsr-x (setGID confirmed) systemctl is-active nginx # active curl localhost # Deployed by RHCSA Student ls -Z /var/www/html/index.html # httpd_sys_content_t getenforce # Enforcing firewall-cmd --list-services # includes http https systemctl is-active sshd # active grep "PermitRootLogin no" /etc/ssh/sshd_config grep "PasswordAuthentication no" /etc/ssh/sshd_config grep "AllowGroups webteam" /etc/ssh/sshd_config /usr/local/bin/web_backup.sh # Backup created: ... crontab -l # 0 2 * * * ... systemctl list-timers | grep web # web-backup.timer listed timedatectl | grep "Time zone" # America/New_York chronyc tracking # synced podman ps # webmon Up systemctl is-active container-webmon # active systemctl get-default # multi-user.target journalctl --disk-usage # max 300.0M ls /var/log/journal/ # journal files present systemctl is-active autofs # active
If anything failed after reboot
| Symptom | Likely cause | Fix |
|---|---|---|
| LV not mounted | Bad UUID or fstab syntax error | Boot rescue mode, fix fstab, reboot |
curl localhost returns 403 | SELinux context wrong | restorecon -Rv /var/www/html |
| nginx not running | Failed to start | journalctl -u nginx to see error |
| Container not running | Unit not enabled or daemon not reloaded | systemctl daemon-reload && systemctl enable —now container-webmon |
| Timer not active | daemon-reload not run | systemctl daemon-reload && systemctl enable —now web-backup.timer |
| NTP not syncing | Wrong server or chrony not restarted | Check /etc/chrony.conf, restart chronyd |
| autofs not mounting | Map file syntax error | Check /etc/auto.content, restart autofs |
Project 1 Walkthrough, RHCSA Capstone Projects Work through every step, check every output, reboot and verify before marking complete
If anything failed after reboot
| Symptom | Likely cause | Fix |
|---|---|---|
| LV not mounted | Bad UUID or fstab syntax error | Boot rescue mode, fix fstab, reboot |
curl localhost returns 403 | SELinux context wrong | restorecon -Rv /var/www/html |
| nginx not running | Failed to start | journalctl -u nginx to see error |
| Container not running | Unit not enabled or daemon not reloaded | systemctl daemon-reload && systemctl enable —now container-webmon |
| Timer not active | daemon-reload not run | systemctl daemon-reload && systemctl enable —now web-backup.timer |
| NTP not syncing | Wrong server or chrony not restarted | Check /etc/chrony.conf, restart chronyd |
| autofs not mounting | Map file syntax error | Check /etc/auto.content, restart autofs |
Task 1: Storage
| Mistake | What goes wrong | Fix |
|---|---|---|
Using device path /dev/vg_web/lv_webdata in fstab instead of UUID | Device names can change on reboot, system may fail to boot | Always use blkid to get the UUID and use that in fstab |
Forgetting mount -a after editing fstab | You think it’s mounted but it isn’t until reboot | Run mount -a immediately and check for errors |
Not running partprobe after fdisk | Kernel doesn’t see new partitions | Run partprobe /dev/sdb after any partition changes |
| Creating the LV before the VG | lvcreate fails | Always: pvcreate → vgcreate → lvcreate |
Task 2: Users and permissions
| Mistake | What goes wrong | Fix |
|---|---|---|
Setting chmod 2770 instead of 2775 | Others (like nginx) can’t read web files | Use 2775, owner:full, group:full, others:read+execute |
Forgetting chown root:webteam before chmod | setGID bit set on wrong group | Always set ownership before permissions |
Using useradd webadmin without -g webteam | User gets a new group with same name, not webteam | Always specify -g for primary group |
Task 3: nginx and firewall
| Mistake | What goes wrong | Fix |
|---|---|---|
Running firewall-cmd without —permanent | Rules disappear after reboot | Always use —permanent then —reload |
Forgetting firewall-cmd —reload | Permanent rules don’t activate immediately | Run —reload after every —permanent change |
| Starting nginx before SELinux context is set | nginx may refuse to serve files | Set SELinux context (Task 4) before testing with curl |
Task 4: SELinux
| Mistake | What goes wrong | Fix |
|---|---|---|
Running semanage fcontext but forgetting restorecon | Context rule saved but not applied to existing files | Always follow semanage with restorecon -Rv |
Using chcon instead of semanage | Context is set but lost after restorecon or relabeling | Use semanage fcontext for persistent changes |
Not including (/.*)? in the fcontext path | Only the directory gets the context, not files inside it | Pattern must be /path(/.*)? to cover contents |
| SELinux is permissive, curl works but context is wrong | You think it’s working but it will break in enforcing mode | Check with getenforce and fix context properly |
Task 5: SSH hardening
| Mistake | What goes wrong | Fix |
|---|---|---|
| Locking yourself out by not adding your user to webteam first | Can’t SSH back in | Run usermod -aG webteam $(whoami) BEFORE restarting sshd |
Setting AllowGroups but not restarting sshd | Change has no effect | Always systemctl restart sshd after config changes |
Disabling PasswordAuthentication without SSH keys set up | Can’t log in at all | Ensure key-based auth works before disabling passwords |
| Typos in sshd_config | sshd fails to start | Use sshd -t to test config syntax before restarting |
Task 6: Backup script
| Mistake | What goes wrong | Fix |
|---|---|---|
Forgetting chmod +x | Script won’t execute, “Permission denied” | Always chmod +x after creating a script |
Not using #!/bin/bash shebang | Script may run with wrong shell | First line must always be #!/bin/bash |
Hardcoding the date instead of using $(date +%Y%m%d) | Archive always has the same name | Use command substitution for dynamic filenames |
| Not testing the script manually before scheduling it | Cron runs silently, you won’t know it’s broken | Always run the script manually first |
Task 7: Cron and systemd timer
| Mistake | What goes wrong | Fix |
|---|---|---|
Forgetting systemctl daemon-reload after creating unit files | systemd doesn’t know about the new units | Always reload after creating or editing unit files |
| Enabling the service instead of the timer | Service runs once at enable, not on a schedule | Enable and start the .timer unit, not the .service |
Wrong cron syntax (e.g. 2 * * * * instead of 0 2 * * *) | Job runs at wrong time | Field order: minute hour day month weekday |
Task 8: NTP
| Mistake | What goes wrong | Fix |
|---|---|---|
| Adding server lines without removing old pool lines | Multiple conflicting sources | Comment out existing pool lines before adding server |
| Forgetting to restart chronyd | New config not loaded | systemctl restart chronyd after every change |
Not verifying with chronyc sources | You assume it works but it may not be syncing | Always verify, look for ^* which means synchronized |
Task 9: Podman container
| Mistake | What goes wrong | Fix |
|---|---|---|
Forgetting :Z on the volume mount | SELinux blocks container from reading the mounted directory | Always use :Z when mounting host dirs into containers on RHEL |
Placing unit file in ~/.config/systemd/user/ instead of /etc/systemd/system/ | Container only starts when root logs in, not at boot | System-level containers go in /etc/systemd/system/ |
Forgetting systemctl daemon-reload after moving unit file | systemd can’t find the unit | Always daemon-reload after placing new unit files |
Not using —new flag with podman generate systemd | Unit file references container by ID, breaks after recreation | Always use —new so unit recreates the container fresh |
Task 10: Journal and boot target
| Mistake | What goes wrong | Fix |
|---|---|---|
Editing journald.conf but not creating /var/log/journal/ | Journal still writes to memory (tmpfs) | Create the directory: mkdir -p /var/log/journal |
Setting Storage=persistent but not restarting journald | Change not applied | systemctl restart systemd-journald |
Using systemctl isolate multi-user.target instead of set-default | Only changes current session, not persistent | Use systemctl set-default multi-user.target |
Task 11: autofs
| Mistake | What goes wrong | Fix |
|---|---|---|
Putting the map entry in /etc/auto.master directly instead of /etc/auto.master.d/ | Works but not the preferred RHEL 9 method | Use drop-in files in /etc/auto.master.d/ |
| Forgetting to restart autofs after config changes | Old config still active | systemctl restart autofs after any map changes |
| Accessing the wrong path to trigger the mount | Mount never triggers | Access exactly /mnt/content, not /mnt/ alone |
General Mistakes That Fail the Exam
| Mistake | Impact |
|---|---|
| Not rebooting to verify persistence | You pass tasks manually but fail the reboot check |
Using setenforce 1 but not editing /etc/selinux/config | SELinux reverts to permissive after reboot |
Running firewall-cmd rules without —permanent | Firewall resets to default after reboot |
Creating unit files but forgetting systemctl enable | Service/timer doesn’t start at boot |
Setting timezone with tzdata manually instead of timedatectl | May not survive reboot correctly |
| Editing config files without verifying syntax | Services fail silently on next reboot |