sysadmin · May 4, 2026

Deploy a Secure Web Server with Persistent Storage

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

RHCSA EX200 Exam · Project 1 of 10
#rhcsa#ex200#rhel9#lvm#selinux#nginx#systemd#podman#firewalld#exam

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.

note: You need a clean RHEL 9 VM with root access, one extra unpartitioned disk at /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.
// 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: Partition and mount web storage

Domain 4 + Domain 5

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

Domain 1

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

Domain 6

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

Domain 5

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

Domain 3 + Domain 6

Edit /etc/ssh/sshd_config to:

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

Domain 2

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

Task 7: Schedule the backup with cron and a systemd timer

Domain 6
  1. Add a root crontab entry to run /usr/local/bin/web_backup.sh every night at 2:00 AM
  2. Create a systemd service unit at /etc/systemd/system/web-backup.service and a timer unit at /etc/systemd/system/web-backup.timer that runs the backup every 6 hours. Enable and start the timer.

Task 8: Configure NTP and timezone

Domain 6

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

Domain 6

As root, pull registry.access.redhat.com/ubi9/ubi-minimal:latest. Run a detached container named webmon that:

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

Domain 3

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

Domain 5

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

All Domains

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

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

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:

bash
/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: active

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

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

bash
ls /var/log/journal/
# Should show a directory with journal files

Autofs (Task 11)

Accessing /mnt/content triggers the NFS mount automatically.

Test:

bash
ls /mnt/content
# Should list files from the NFS export

Expected output of systemctl is-active autofs:

active
Part 2: The WalkthroughStep by step, with a conceptual deep dive before each task.

Task 1: Partition and Mount Web Storage

Domain 4 + Domain 5
// deep dive

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

bash
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

bash
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

bash
vgcreate vg_web /dev/sdb

Expected output:

Volume group "vg_web" successfully created

Verify:

bash
vgs

vg_web should appear with available free space.

Step 1.4: Create the Logical Volume

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

bash
lvs

Step 1.5: Format as XFS

bash
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

bash
mkdir -p /var/www/html

Step 1.7: Get the UUID

bash
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

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

Step 1.9: Mount and verify

bash
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
checkpoint: Checkpoint: If df -h /var/www/html still shows the root filesystem, the mount failed. Fix fstab before continuing.

Task 2: Create the Web Service Account

Domain 1
// deep dive

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

bash
groupadd -g 6000 webteam

Verify:

bash
getent group webteam

Expected: webteam:x:6000:

Step 2.2: Create the webadmin user

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

bash
id webadmin

Expected: uid=6001(webadmin) gid=6000(webteam) groups=6000(webteam)

Step 2.3: Set directory ownership

bash
chown root:webteam /var/www/html

Step 2.4: Set permissions with setGID

bash
chmod 2775 /var/www/html

Breaking down 2775:

Verify:

bash
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

Domain 6
// deep dive

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

bash
dnf install -y nginx

Step 3.2: Enable and start nginx

bash
systemctl enable --now nginx

enable makes nginx start at boot. —now starts it immediately.

Verify:

bash
systemctl is-active nginx

Expected: active

Step 3.3: Open the firewall

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

bash
firewall-cmd --list-services

http and https should appear in the list.

Step 3.4: Create the web page

bash
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

Domain 5
// deep dive

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

bash
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

bash
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

bash
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

bash
ls -Z /var/www/html/index.html

Expected: unconfined_u:object_r:httpd_sys_content_t:s0 /var/www/html/index.html

checkpoint: Checkpoint: curl localhost must return Deployed by RHCSA Student before moving on.

Task 5: Harden SSH Access

Domain 3 + Domain 6
// deep dive

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.

warning: WARNING: Do this carefully. Wrong steps can lock you out permanently.

Step 5.1: Add your user to webteam FIRST

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

bash
id $(whoami)

webteam must appear in the groups list before proceeding.

Step 5.2: Confirm key-based SSH works

bash
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

bash
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

bash
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

bash
systemctl restart sshd
systemctl is-active sshd

Expected: active

Task 6: Write an Automated Backup Script

Domain 2
// deep dive

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

bash
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
EOF

Script breakdown:

Step 6.2: Make it executable

bash
chmod +x /usr/local/bin/web_backup.sh

Step 6.3: Test it manually

bash
/usr/local/bin/web_backup.sh

Expected output:

Backup created: /backups/web_20260420.tar.gz

Verify the archive:

bash
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

Domain 6
// deep dive

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

bash
crontab -e

Add:

0 2 * * * /usr/local/bin/web_backup.sh

Cron field order: minute hour day month weekday command

Verify:

bash
crontab -l

Step 7.2: Create the systemd service unit

bash
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

bash
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

bash
systemctl daemon-reload
systemctl enable --now web-backup.timer

Always daemon-reload first. Enable the TIMER, not the service.

Verify:

bash
systemctl list-timers | grep web

Task 8: Configure NTP and Timezone

Domain 6
// deep dive

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

bash
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

bash
sed -i 's/^pool/#pool/' /etc/chrony.conf
sed -i 's/^server/#server/' /etc/chrony.conf

Verify nothing remains uncommented:

bash
grep -E "^pool|^server" /etc/chrony.conf

Should return nothing.

Step 8.3: Add the new NTP server

bash
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

bash
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

Domain 6
// deep dive

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

bash
podman pull registry.access.redhat.com/ubi9/ubi-minimal:latest
podman images

Step 9.2: Ensure the nginx log directory exists

bash
mkdir -p /var/log/nginx

Step 9.3: Run the container

bash
podman run -d --name webmon \
  -v /var/log/nginx:/logs:Z \
  registry.access.redhat.com/ubi9/ubi-minimal:latest \
  sleep infinity

Flag breakdown:

Verify:

bash
podman ps

Step 9.4: Generate the systemd unit file

bash
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

bash
mv container-webmon.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now container-webmon.service

Verify:

bash
systemctl is-active container-webmon
podman ps

Task 10: Make the Journal Persistent and Set Boot Target

Domain 3
// deep dive

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

bash
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

bash
mkdir -p /var/log/journal

This is required. Without it journald stays in memory even with Storage=persistent set.

Step 10.3: Restart journald

bash
systemctl restart systemd-journald
journalctl --disk-usage
ls /var/log/journal/

Step 10.4: Set the default boot target

bash
systemctl set-default multi-user.target
systemctl get-default

Expected: multi-user.target

Task 11: Configure autofs

Domain 5
// deep dive

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

bash
dnf install -y autofs

Step 11.2: Create the master map entry

bash
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

bash
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

bash
systemctl enable --now autofs
ls /mnt/content

Accessing the path triggers the mount automatically.

Task 12: Reboot and Verify Everything

All Domains
// deep dive

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.

bash
reboot

After reboot, run the full checklist:

bash
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

SymptomLikely causeFix
LV not mountedBad UUID or fstab syntax errorBoot rescue mode, fix fstab, reboot
curl localhost returns 403SELinux context wrongrestorecon -Rv /var/www/html
nginx not runningFailed to startjournalctl -u nginx to see error
Container not runningUnit not enabled or daemon not reloadedsystemctl daemon-reload && systemctl enable —now container-webmon
Timer not activedaemon-reload not runsystemctl daemon-reload && systemctl enable —now web-backup.timer
NTP not syncingWrong server or chrony not restartedCheck /etc/chrony.conf, restart chronyd
autofs not mountingMap file syntax errorCheck /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

SymptomLikely causeFix
LV not mountedBad UUID or fstab syntax errorBoot rescue mode, fix fstab, reboot
curl localhost returns 403SELinux context wrongrestorecon -Rv /var/www/html
nginx not runningFailed to startjournalctl -u nginx to see error
Container not runningUnit not enabled or daemon not reloadedsystemctl daemon-reload && systemctl enable —now container-webmon
Timer not activedaemon-reload not runsystemctl daemon-reload && systemctl enable —now web-backup.timer
NTP not syncingWrong server or chrony not restartedCheck /etc/chrony.conf, restart chronyd
autofs not mountingMap file syntax errorCheck /etc/auto.content, restart autofs
Common MistakesThe traps that cost points, and the ones that fail the whole exam.

Task 1: Storage

MistakeWhat goes wrongFix
Using device path /dev/vg_web/lv_webdata in fstab instead of UUIDDevice names can change on reboot, system may fail to bootAlways use blkid to get the UUID and use that in fstab
Forgetting mount -a after editing fstabYou think it’s mounted but it isn’t until rebootRun mount -a immediately and check for errors
Not running partprobe after fdiskKernel doesn’t see new partitionsRun partprobe /dev/sdb after any partition changes
Creating the LV before the VGlvcreate failsAlways: pvcreatevgcreatelvcreate

Task 2: Users and permissions

MistakeWhat goes wrongFix
Setting chmod 2770 instead of 2775Others (like nginx) can’t read web filesUse 2775, owner:full, group:full, others:read+execute
Forgetting chown root:webteam before chmodsetGID bit set on wrong groupAlways set ownership before permissions
Using useradd webadmin without -g webteamUser gets a new group with same name, not webteamAlways specify -g for primary group

Task 3: nginx and firewall

MistakeWhat goes wrongFix
Running firewall-cmd without —permanentRules disappear after rebootAlways use —permanent then —reload
Forgetting firewall-cmd —reloadPermanent rules don’t activate immediatelyRun —reload after every —permanent change
Starting nginx before SELinux context is setnginx may refuse to serve filesSet SELinux context (Task 4) before testing with curl

Task 4: SELinux

MistakeWhat goes wrongFix
Running semanage fcontext but forgetting restoreconContext rule saved but not applied to existing filesAlways follow semanage with restorecon -Rv
Using chcon instead of semanageContext is set but lost after restorecon or relabelingUse semanage fcontext for persistent changes
Not including (/.*)? in the fcontext pathOnly the directory gets the context, not files inside itPattern must be /path(/.*)? to cover contents
SELinux is permissive, curl works but context is wrongYou think it’s working but it will break in enforcing modeCheck with getenforce and fix context properly

Task 5: SSH hardening

MistakeWhat goes wrongFix
Locking yourself out by not adding your user to webteam firstCan’t SSH back inRun usermod -aG webteam $(whoami) BEFORE restarting sshd
Setting AllowGroups but not restarting sshdChange has no effectAlways systemctl restart sshd after config changes
Disabling PasswordAuthentication without SSH keys set upCan’t log in at allEnsure key-based auth works before disabling passwords
Typos in sshd_configsshd fails to startUse sshd -t to test config syntax before restarting

Task 6: Backup script

MistakeWhat goes wrongFix
Forgetting chmod +xScript won’t execute, “Permission denied”Always chmod +x after creating a script
Not using #!/bin/bash shebangScript may run with wrong shellFirst line must always be #!/bin/bash
Hardcoding the date instead of using $(date +%Y%m%d)Archive always has the same nameUse command substitution for dynamic filenames
Not testing the script manually before scheduling itCron runs silently, you won’t know it’s brokenAlways run the script manually first

Task 7: Cron and systemd timer

MistakeWhat goes wrongFix
Forgetting systemctl daemon-reload after creating unit filessystemd doesn’t know about the new unitsAlways reload after creating or editing unit files
Enabling the service instead of the timerService runs once at enable, not on a scheduleEnable and start the .timer unit, not the .service
Wrong cron syntax (e.g. 2 * * * * instead of 0 2 * * *)Job runs at wrong timeField order: minute hour day month weekday

Task 8: NTP

MistakeWhat goes wrongFix
Adding server lines without removing old pool linesMultiple conflicting sourcesComment out existing pool lines before adding server
Forgetting to restart chronydNew config not loadedsystemctl restart chronyd after every change
Not verifying with chronyc sourcesYou assume it works but it may not be syncingAlways verify, look for ^* which means synchronized

Task 9: Podman container

MistakeWhat goes wrongFix
Forgetting :Z on the volume mountSELinux blocks container from reading the mounted directoryAlways 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 bootSystem-level containers go in /etc/systemd/system/
Forgetting systemctl daemon-reload after moving unit filesystemd can’t find the unitAlways daemon-reload after placing new unit files
Not using —new flag with podman generate systemdUnit file references container by ID, breaks after recreationAlways use —new so unit recreates the container fresh

Task 10: Journal and boot target

MistakeWhat goes wrongFix
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 journaldChange not appliedsystemctl restart systemd-journald
Using systemctl isolate multi-user.target instead of set-defaultOnly changes current session, not persistentUse systemctl set-default multi-user.target

Task 11: autofs

MistakeWhat goes wrongFix
Putting the map entry in /etc/auto.master directly instead of /etc/auto.master.d/Works but not the preferred RHEL 9 methodUse drop-in files in /etc/auto.master.d/
Forgetting to restart autofs after config changesOld config still activesystemctl restart autofs after any map changes
Accessing the wrong path to trigger the mountMount never triggersAccess exactly /mnt/content, not /mnt/ alone

General Mistakes That Fail the Exam

MistakeImpact
Not rebooting to verify persistenceYou pass tasks manually but fail the reboot check
Using setenforce 1 but not editing /etc/selinux/configSELinux reverts to permissive after reboot
Running firewall-cmd rules without —permanentFirewall resets to default after reboot
Creating unit files but forgetting systemctl enableService/timer doesn’t start at boot
Setting timezone with tzdata manually instead of timedatectlMay not survive reboot correctly
Editing config files without verifying syntaxServices fail silently on next reboot
next post
RHCSA EX200 Project 2: Multi-User Dev Environment