sysadmin · May 12, 2026

Configure a Centralized Log Management Server

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

RHCSA EX200 Exam · Project 3 of 10
#rhcsa#ex200#rhel9#rsyslog#logrotate#lvm#systemd#podman#firewalld#exam

Scenario: Your organization needs logserver1.lab.example.com set up as a centralized logging and monitoring server. You provision dedicated LVM storage for logs, configure rsyslog to receive logs from remote systems, write monitoring scripts, schedule health checks, manage and harden services, deploy a container-based log viewer, and make sure everything survives a reboot.

Domains Covered: All 6 (D1 through D6)

Estimated Time: 90–120 minutes

Difficulty: Exam-level

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

note: You need a clean RHEL 9 VM with root access, one extra disk at /dev/sdb of at least 5 GiB (Task 1 uses 3.5 GiB and Task 10 extends the volume by another 1 GiB), and internet access. dnf and podman pull fetch packages and one public image (ubi9/ubi, no login required). This server is configured to receive logs from remote hosts, so the per-host files under /var/log/remote only appear once real clients send logs to it. 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: Provision dedicated log storage

Domain 4 + Domain 5

Create a 3 GiB LVM Logical Volume named lv_logs in a new Volume Group vg_logs using /dev/sdb. Format it as XFS. Mount it persistently at /var/log/remote using its UUID in /etc/fstab. Set ownership to root:root and permissions to 755.

Also create a 512 MiB Logical Volume named lv_archive in the same VG. Format as XFS and mount persistently at /var/log/archive.

Task 2: Configure rsyslog to receive remote logs

Domain 6

Configure rsyslog to:

Open port 514 (both UDP and TCP) permanently in firewalld.

Task 3: Configure rsyslog log rotation

Domain 6

Create a logrotate configuration file at /etc/logrotate.d/remote-logs that:

Task 4: Write a disk usage alert script

Domain 2

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

Task 5: Write a log summary script

Domain 2

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

Task 6: Schedule monitoring tasks

Domain 6
  1. Schedule disk_alert.sh to run every 30 minutes via a root crontab entry
  2. Schedule log_summary.sh to run every day at midnight via a root crontab entry
  3. Create a systemd service /etc/systemd/system/log-monitor.service that runs disk_alert.sh 90 at boot
  4. Create a systemd timer /etc/systemd/system/log-monitor.timer that triggers log-monitor.service every hour
  5. Enable and start the timer

Task 7: Manage and harden services

Domain 3

Ensure the following service states and verify each survives a reboot:

After making changes, check for any failed units with systemctl list-units —state=failed and write the output to /root/failed_units.txt.

Task 8: Configure log archiving with ACLs

Domain 5

Create the group logadmins (GID 8000) and users logadmin1 (UID 8001) and logadmin2 (UID 8002), both with primary group logadmins and no login shell.

Set the following on /var/log/archive:

Verify with getfacl /var/log/archive.

Task 9: Deploy a container-based log viewer

Domain 6

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

Generate a systemd unit file, place it in /etc/systemd/system/, enable it so it starts at boot.

Task 10: Extend log storage online

Domain 4 + Domain 5

The lv_logs volume is getting full. Extend it by 1 GiB without unmounting or losing any data. Resize the XFS filesystem online using xfs_growfs. Verify the new size with df -h /var/log/remote.

Task 11: Configure NTP, hostname, and boot target

Domain 6

Set the system hostname to logserver1.lab.example.com. Set the timezone to America/Los_Angeles. Configure chronyd to use time.cloudflare.com iburst as its NTP source. Set the default boot target to multi-user.target. Verify all settings after reboot.

Task 12: Reboot and verify everything

All Domains

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

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

Storage (Tasks 1, 10)

Expected output of df -h /var/log/remote:

Filesystem                    Size  Used Avail Use% Mounted on
/dev/mapper/vg_logs-lv_logs   4.0G   50M  3.9G   2% /var/log/remote

*(4G after the Task 10 extension)*

Expected output of df -h /var/log/archive:

Filesystem                      Size  Used Avail Use% Mounted on
/dev/mapper/vg_logs-lv_archive  512M   20M  492M   4% /var/log/archive

Expected output of lvs:

  LV         VG      Attr       LSize
  lv_archive vg_logs -wi-ao---- 512.00m
  lv_logs    vg_logs -wi-ao---- 4.00g

rsyslog (Tasks 2, 3)

Verify rsyslog is listening on port 514:

bash
ss -ulnp | grep 514    # UDP
ss -tlnp | grep 514    # TCP

Expected output:

udp  UNCONN 0  0  0.0.0.0:514  0.0.0.0:*  users:(("rsyslogd",...))
tcp  LISTEN 0  128  0.0.0.0:514  0.0.0.0:*  users:(("rsyslogd",...))

Verify firewall rules:

bash
firewall-cmd --list-ports
# 514/udp 514/tcp

Verify logrotate config:

bash
logrotate --debug /etc/logrotate.d/remote-logs
# Should show rotation would occur without errors

Scripts (Tasks 4, 5)

Test disk alert script:

bash
/usr/local/bin/disk_alert.sh 80
# OK: /var/log/remote is 2% full
# OK: /var/log/archive is 4% full

cat /var/log/disk_alerts.log
# [Mon Apr 20 12:00:00 EDT 2026] OK: /var/log/remote is 2% full

Test log summary script:

bash
/usr/local/bin/log_summary.sh
ls /var/log/summary_*.txt
cat /var/log/summary_20260420.txt

Scheduling (Task 6)

Verify cron jobs:

bash
crontab -l
# */30 * * * * /usr/local/bin/disk_alert.sh
# 0 0 * * * /usr/local/bin/log_summary.sh

Verify systemd timer:

bash
systemctl is-active log-monitor.timer
# active

systemctl list-timers | grep log-monitor
# log-monitor.timer  ...  1h  log-monitor.service

Services (Task 7)

bash
systemctl is-active rsyslog       # active
systemctl is-active firewalld     # active
systemctl is-active chronyd       # active
systemctl is-active cups          # inactive
systemctl is-active avahi-daemon  # inactive
systemctl is-enabled cups         # disabled

ACLs (Task 8)

Expected output of getfacl /var/log/archive:

# file: var/log/archive
# owner: root
# group: logadmins
user::rwx
user:logadmin1:rwx
group::r-x
group:logadmins:rw-
user:logadmin2:r-x
mask::rwx
other::---
default:group:logadmins:rw-

Container (Task 9)

bash
podman ps
# logviewer   ubi9/ubi:latest   sleep infinity   Up ...

systemctl is-active container-logviewer
# active

podman exec logviewer ls /logs
# Should list files from /var/log/remote

Hostname and NTP (Task 11)

bash
hostname
# logserver1.lab.example.com

timedatectl | grep "Time zone"
# Time zone: America/Los_Angeles (PDT, -0700)

chronyc sources | grep cloudflare
# ^* time.cloudflare.com ...

systemctl get-default
# multi-user.target
Part 2: The WalkthroughStep by step, with a conceptual deep dive before each task.

Task 1: Provision dedicated log storage

Domain 4 + Domain 5
// deep dive

Putting incoming logs on their own logical volumes is a deliberate isolation choice. If remote hosts flood this server, the worst case is that /var/log/remote fills up; the root filesystem, and therefore the ability to log in and fix the problem, stays healthy. This is the same reasoning behind giving /var or /home separate volumes in production.

The mechanics are the standard LVM stack: a physical volume initialises the disk for LVM, a volume group pools that space, and logical volumes are carved out of the pool. Because both volumes come from one vg_logs, they draw from a shared pool, which is what lets Task 10 hand more space to lv_logs later without touching the disk. That only works if you leave free extents in the group, so a 5 GiB disk holding a 3 GiB and a 512 MiB volume keeps room to grow.

Mount by UUID rather than device path. Device names like /dev/sdb can shift if disks are added or reordered, but a filesystem UUID is stable, so the fstab entry stays correct across reboots.

Goal: Two LVM volumes in a new vg_logs: a 3 GiB lv_logs at /var/log/remote and a 512 MiB lv_archive at /var/log/archive, both persistent.

Step 1.1: Create the volume group and logical volumes

bash
pvcreate /dev/sdb
vgcreate vg_logs /dev/sdb
lvcreate -L 3G -n lv_logs vg_logs
lvcreate -L 512M -n lv_archive vg_logs
mkfs.xfs /dev/vg_logs/lv_logs
mkfs.xfs /dev/vg_logs/lv_archive

Leave free space in the VG: Task 10 grows lv_logs by 1 GiB.

Step 1.2: Mount persistently by UUID

bash
mkdir -p /var/log/remote /var/log/archive
UUID1=$(blkid -s UUID -o value /dev/vg_logs/lv_logs)
UUID2=$(blkid -s UUID -o value /dev/vg_logs/lv_archive)
echo "UUID=$UUID1  /var/log/remote   xfs  defaults  0 0" >> /etc/fstab
echo "UUID=$UUID2  /var/log/archive  xfs  defaults  0 0" >> /etc/fstab
mount -a

Mount by UUID so a disk reorder cannot break the boot.

Step 1.3: Ownership, permissions, and SELinux label

bash
chown root:root /var/log/remote /var/log/archive
chmod 755 /var/log/remote /var/log/archive
restorecon -Rv /var/log/remote /var/log/archive
df -h /var/log/remote /var/log/archive

The restorecon matters here. A freshly formatted XFS volume mounts without the right SELinux context, and rsyslog runs confined, so it is denied writes to /var/log/remote until the label is corrected to var_log_t. restorecon applies the context the policy expects for that path.

checkpoint: both filesystems appear in df, and ls -Zd /var/log/remote shows var_log_t.

Task 2: Configure rsyslog to receive remote logs

Domain 6
// deep dive

By default rsyslog only handles local messages. To accept logs from other machines you load a network input module and tell it which port to listen on. imudp is the UDP receiver and imtcp is the TCP receiver; syslog traditionally uses port 514 for both. UDP is fast and lossy, fine for high-volume telemetry where a dropped line does not matter; TCP is reliable and ordered, better when you cannot afford to lose events. This server accepts both.

A template defines an output path or message format, and here it uses the %HOSTNAME% property to build a per-sender filename. The rule *.* ?RemoteLogs says route messages of every facility and priority through that template, so each remote host lands in its own file under /var/log/remote. Module-load directives have to appear before the rules that depend on them.

Two things are easy to forget. The firewall must allow 514 on both protocols with —permanent so it survives a reboot, and the receiving directory needs the correct SELinux label, since a confined rsyslog cannot write to a freshly mounted volume that still carries the wrong context.

Goal: Listen on UDP and TCP 514, write one file per remote host, and open the firewall.

Step 2.1: Enable the input modules

bash
vim /etc/rsyslog.conf

Uncomment (remove the leading # from) these lines:

module(load="imudp")
input(type="imudp" port="514")
module(load="imtcp")
input(type="imtcp" port="514")

Step 2.2: Add the per-host template and rule

bash
cat >> /etc/rsyslog.conf <<'EOF'
$template RemoteLogs,"/var/log/remote/%HOSTNAME%.log"
*.* ?RemoteLogs
EOF

%HOSTNAME% expands to the sending host, so each remote system gets its own file under /var/log/remote. The module-load lines must come before this rule.

Step 2.3: Validate, restart, and confirm it is listening

bash
rsyslogd -N1
systemctl restart rsyslog
ss -ulnp | grep 514
ss -tlnp | grep 514

rsyslogd -N1 checks the config syntax before you restart, so a typo cannot leave rsyslog down.

Step 2.4: Open the firewall

bash
firewall-cmd --permanent --add-port=514/udp
firewall-cmd --permanent --add-port=514/tcp
firewall-cmd --reload
firewall-cmd --list-ports

—permanent then —reload is what makes the rule survive a reboot.

checkpoint: ss shows rsyslogd listening on 0.0.0.0:514 for both UDP and TCP, and firewall-cmd —list-ports shows 514/udp and 514/tcp.

Task 3: Configure log rotation

Domain 6
// deep dive

Log files grow without bound unless something trims them, and that something is logrotate. It runs on a schedule (a daily systemd timer on RHEL 9) and, for each managed path, decides whether to rotate based on the directives you give it.

The directives in this config each do one job. daily sets the cadence and rotate 30 keeps thirty old copies, so together they retain a month. compress gzips the rotated files to save space. missingok stops logrotate complaining if a matching file does not exist yet. notifempty skips rotation when the file is empty, which avoids a pile of useless empty archives.

The postrotateendscript block runs a command after rotation. Here it restarts rsyslog so it reopens its file handles and writes to the fresh file rather than the now-renamed old one. That block must be closed with endscript or the whole config fails to parse. Test with logrotate —debug, which simulates a run and reports what it would do without actually rotating anything.

Goal: Rotate the per-host logs daily, keep 30 days, compress, skip empty files, restart rsyslog after.

Step 3.1: Create the logrotate config

bash
cat > /etc/logrotate.d/remote-logs <<'EOF'
/var/log/remote/*.log {
    daily
    rotate 30
    compress
    missingok
    notifempty
    postrotate
        systemctl restart rsyslog > /dev/null 2>&1 || true
    endscript
}
EOF

daily plus rotate 30 keeps thirty days. notifempty skips empty files. The postrotate block must close with endscript.

Step 3.2: Test it without rotating anything

bash
logrotate --debug /etc/logrotate.d/remote-logs

—debug shows what would happen without touching the logs. Do not test with —force, which actually rotates them.

checkpoint: logrotate —debug runs with no syntax errors and reports it considered /var/log/remote/*.log.

Task 4: Write a disk usage alert script

Domain 2
// deep dive

A good alert script does three things: it makes a decision, it records that decision somewhere durable, and it signals the result through its exit code so other tools can react. This one does all three.

THRESHOLD=${1:-80} is parameter expansion with a default: use the first argument if given, otherwise 80. That makes the script usable both interactively and from a timer that passes a stricter number. The exit code is the important contract: the script returns 1 if any filesystem is over the limit and 0 if all are fine, so a monitoring system can treat a nonzero exit as a real alert.

Parsing df output is where these scripts quietly break. Plain df wraps long device names onto a second line, which throws off a fixed NR==2 field grab. df -P forces the POSIX single-line format, so the use-percentage is always on the second line in the fifth column. Stripping the % with tr then gives a clean integer for the numeric comparison.

Goal: A threshold-based alert script that logs its output and returns a meaningful exit code.

Step 4.1: Create the script

bash
cat > /usr/local/bin/disk_alert.sh <<'EOF'
#!/bin/bash
THRESHOLD=${1:-80}
LOG="/var/log/disk_alerts.log"
EXIT_CODE=0

for MOUNT in /var/log/remote /var/log/archive; do
  USAGE=$(df -P "$MOUNT" | awk 'NR==2 {print $5}' | tr -d '%')
  TIMESTAMP=$(date '+%a %b %d %T %Z %Y')
  if [ "$USAGE" -ge "$THRESHOLD" ]; then
    MSG="ALERT: $MOUNT is ${USAGE}% full"
    EXIT_CODE=1
  else
    MSG="OK: $MOUNT is ${USAGE}% full"
  fi
  echo "$MSG"
  echo "[$TIMESTAMP] $MSG" >> "$LOG"
done

exit $EXIT_CODE
EOF

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

Two details. ${1:-80} defaults the threshold to 80 when no argument is given. And the df -P (POSIX output) keeps each filesystem on a single line, so the awk ‘NR==2’ parse does not break on a long device-mapper name like /dev/mapper/vg_logs-lv_logs, which plain df can wrap onto two lines.

Step 4.2: Test

bash
/usr/local/bin/disk_alert.sh 80
echo "exit code: $?"
cat /var/log/disk_alerts.log
checkpoint: it prints OK or ALERT for both mounts, appends timestamped lines to the log, and exits 1 only when something is at or above the threshold.

Task 5: Write a log summary script

Domain 2
// deep dive

Counting matches in a file is a one-line job with grep, but two flags and one gotcha decide whether it is correct. -i makes the match case-insensitive, so Error, ERROR, and error all count; without it you silently miss most real-world log lines. -c prints the count instead of the matching lines.

The gotcha is grep’s exit code. When it finds no matches it still prints 0, but it also exits with status 1. A reflex like grep -ci pattern file || echo 0 therefore fires the fallback on a zero-match file and prints a second zero, turning a clean count into a corrupted two-line value. The fix is simply not to add the fallback: capture grep -c directly, since it already reports zero correctly.

The loop also guards against an empty match. When a glob like /var/log/remote/*.log matches nothing, the shell leaves the literal pattern in place, so [ -f “$f” ] || continue skips that non-file and keeps the script from reporting a phantom host.

Goal: Count error, warning, and critical lines per host log.

Step 5.1: Create the script

bash
cat > /usr/local/bin/log_summary.sh <<'EOF'
#!/bin/bash
DATE=$(date +%Y%m%d)
SUMMARY="/var/log/summary_${DATE}.txt"
LOG_DIR="/var/log/remote"

> "$SUMMARY"

if [ -n "$1" ]; then
  FILES="$LOG_DIR/$1.log"
else
  FILES="$LOG_DIR"/*.log
fi

for f in $FILES; do
  [ -f "$f" ] || continue
  HOST=$(basename "$f" .log)
  ERRORS=$(grep -ci "error" "$f" 2>/dev/null)
  WARNINGS=$(grep -ci "warning" "$f" 2>/dev/null)
  CRITICAL=$(grep -ci "critical" "$f" 2>/dev/null)
  echo "$HOST: errors=$ERRORS warnings=$WARNINGS critical=$CRITICAL" | tee -a "$SUMMARY"
done

echo "Summary saved to $SUMMARY"
EOF

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

Two things to get right. grep -ci counts case-insensitively, so Error, ERROR, and error all count. And there is deliberately no || echo 0 after the greps: grep -c already prints 0 when there are no matches, so adding || echo 0 would print a second 0 and corrupt the line into errors=0 followed by a stray 0. The [ -f “$f” ] guard handles the case where the glob matches nothing.

Step 5.2: Test

bash
/usr/local/bin/log_summary.sh
cat /var/log/summary_*.txt
checkpoint: one line per host log in the form host: errors=N warnings=N critical=N, saved to the date-stamped file.

Task 6: Schedule monitoring tasks

Domain 6
// deep dive

This task uses both schedulers on purpose, because cron and systemd timers answer the question differently. cron is calendar-based and terse: */30 * * * * means every thirty minutes, 0 0 * * * means midnight. It is perfect for simple, time-of-day jobs.

A systemd timer is a unit that activates another unit on a schedule, and it comes in two flavours. OnCalendar is wall-clock based like cron. OnBootSec and OnUnitActiveSec, used here, are monotonic: the first fires a fixed time after boot, the second a fixed interval after the last run. So this timer runs five minutes after boot and then every hour, regardless of the absolute time.

The key habit is what you enable. The timer drives a paired Type=oneshot service, so you enable —now the timer, not the service. Enabling the service would just run it once. And after writing any unit file you run daemon-reload so systemd reparses it before you enable it. Timers install into timers.target.

Goal: Two cron jobs, plus a systemd timer driving a oneshot service.

Step 6.1: Cron jobs

bash
crontab -e

Add:

*/30 * * * * /usr/local/bin/disk_alert.sh
0 0 * * * /usr/local/bin/log_summary.sh

*/30 is every 30 minutes; 0 0 is midnight.

Step 6.2: systemd service and timer

bash
cat > /etc/systemd/system/log-monitor.service <<'EOF'
[Unit]
Description=Log Monitor Disk Check

[Service]
Type=oneshot
ExecStart=/usr/local/bin/disk_alert.sh 90
EOF

cat > /etc/systemd/system/log-monitor.timer <<'EOF'
[Unit]
Description=Run log monitor every hour

[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
Unit=log-monitor.service

[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload
systemctl enable --now log-monitor.timer
systemctl list-timers | grep log-monitor

You enable the timer, not the service. The timer fires the oneshot service on schedule: five minutes after boot, then every hour.

checkpoint: crontab -l shows both jobs, and systemctl is-active log-monitor.timer is active.

Task 7: Manage and harden services

Domain 3
// deep dive

Service hardening on a server is mostly about reducing what is running to only what is needed. Every active service is a potential attack surface and a consumer of resources, so a logging server has no business running a print spooler (cups) or a desktop discovery daemon (avahi-daemon).

The distinction that trips people up is start/stop versus enable/disable. start and stop act on the running system right now; enable and disable control whether the unit comes up at boot. A service you only stop will quietly return after the next reboot, which is exactly the kind of thing the post-reboot check catches. disable —now does both at once: stop it and keep it from starting again. (mask goes further, making a unit impossible to start at all, but disable is enough here.)

After changing service states, systemctl list-units —state=failed surfaces anything that died or refused to start. Capturing it to a file is good operational hygiene: it is a snapshot you can compare against later.

Goal: Set the required service states and capture any failed units.

Step 7.1: Enable what you want, disable what you do not

bash
systemctl enable --now rsyslog
systemctl enable --now firewalld
systemctl enable --now chronyd
systemctl disable --now cups
systemctl disable --now avahi-daemon

disable —now both stops the service immediately and prevents it from starting at boot. If cups or avahi-daemon is not installed on your minimal system, systemctl reports the unit is not loaded, which is harmless.

Step 7.2: Record failed units

bash
systemctl list-units --state=failed > /root/failed_units.txt
cat /root/failed_units.txt
checkpoint: rsyslog, firewalld, and chronyd are active and enabled; cups and avahi are inactive and disabled; /root/failed_units.txt exists.

Task 8: Configure log archiving with ACLs

Domain 5
// deep dive

Standard permissions cannot express what this task needs: two accounts in the same group that must have different access to the same directory. That is precisely the job of POSIX ACLs, which add named entries beyond the single owner, group, and other.

The service accounts come first. logadmin1 and logadmin2 exist to own access, not to log in, so they get -s /sbin/nologin, a shell that refuses interactive sessions. Their group logadmins owns the directory, and the base mode 750 gives the owning group read and execute while shutting out everyone else.

On top of that, named ACLs split the two users: u:logadmin1:rwx can write, u:logadmin2:r-x can only read. The -d (default) ACL is the part that handles the future: it is a template applied to files created later, so new archives automatically grant the logadmins group read and write without anyone re-running setfacl. Read the result with getfacl, and remember the mask caps the effective rights of every named entry.

Goal: A logadmins group, two no-login service accounts, and ACLs on /var/log/archive.

Step 8.1: Group and service accounts

bash
groupadd -g 8000 logadmins
useradd -u 8001 -g logadmins -s /sbin/nologin logadmin1
useradd -u 8002 -g logadmins -s /sbin/nologin logadmin2

-s /sbin/nologin gives these accounts no interactive login, which is correct for service identities.

Step 8.2: Ownership and ACLs

bash
chown root:logadmins /var/log/archive
chmod 750 /var/log/archive
setfacl -m u:logadmin1:rwx /var/log/archive
setfacl -m u:logadmin2:r-x /var/log/archive
setfacl -d -m g:logadmins:rw /var/log/archive

Set ownership first, then layer the ACLs on top. The -d entry is the default ACL, so files created later inherit read/write for the logadmins group.

Step 8.3: Verify

bash
getfacl /var/log/archive

You should see user:logadmin1:rwx, user:logadmin2:r-x, and default:group:logadmins:rw-. setfacl also fills in the default base entries (default:user::, default:group::, default:other::, default:mask::) automatically.

checkpoint: getfacl shows the two named user ACLs and the default logadmins group entry.

Task 9: Deploy a container-based log viewer

Domain 6
// deep dive

Running a container is easy; making it come back after a reboot is the real task. The approach is to let podman generate a systemd unit that manages the container’s lifecycle.

Two flags on the podman run line carry weight on RHEL. :Z on the volume mount tells podman to relabel the host directory with a private SELinux category so the confined container can actually read it; without it, SELinux denies access even though the bind mount exists. -p 9090:80 publishes the container’s port 80 on the host’s 9090.

podman generate systemd —new writes a unit that creates a fresh container on every start instead of pinning to one container ID, so it keeps working after a reboot or a recreation. The catch: because the unit will create its own container named logviewer, you must remove the one you started by hand first, or enabling the service collides on the name. On newer RHEL, generate systemd is being replaced by Quadlet, where you describe the container in a .container file under /etc/containers/systemd and systemd builds the service for you; the generate approach still works and is what most current exam material uses.

Goal: A detached podman container with a generated systemd unit that starts at boot.

Step 9.1: Pull and run

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

podman run -d --name logviewer \
  -v /var/log/remote:/logs:Z \
  -p 9090:80 \
  -e LOG_DIR=/logs \
  registry.access.redhat.com/ubi9/ubi:latest \
  sleep infinity

:Z relabels the host directory for SELinux so the container can read it. -p maps host port 9090 to container port 80.

Step 9.2: Generate the systemd unit

bash
cd /etc/systemd/system
podman generate systemd --name logviewer --new --files

—new makes the unit create a fresh container on each start rather than tying itself to one container ID, which is what survives reboots and recreation cleanly. —files writes container-logviewer.service into the current directory.

Step 9.3: Remove the manual container, then enable the service

bash
podman rm -f logviewer
systemctl daemon-reload
systemctl enable --now container-logviewer.service
podman ps
systemctl is-active container-logviewer

This is the step people miss. Because —new makes the service start its own container named logviewer, you must remove the one you started by hand first, or the service fails with a name-already-in-use error.

checkpoint: podman ps shows logviewer running, and container-logviewer.service is active and enabled.

Task 10: Extend log storage online

Domain 4 + Domain 5
// deep dive

Growing storage while the system stays online is one of the strongest arguments for LVM, and it is a two-layer operation. The logical volume is the container; the filesystem is what lives inside it. Making more room means enlarging both, in order.

lvextend -L +1G hands another gigabyte of free extents from the volume group to lv_logs. At that instant the block device is bigger, but the filesystem still believes it is the old size, so the extra space is unusable until you grow the filesystem into it.

xfs_growfs does that second step, and three details matter. It takes the mount point, not the device path. It works fully online, with the filesystem mounted and in use, so there is no downtime. And XFS only ever grows: it cannot be shrunk, which is a deliberate design tradeoff, so plan sizes with that in mind. You can collapse both steps into lvextend -r, which resizes the filesystem automatically, but doing them separately makes each layer visible.

Goal: Grow lv_logs by 1 GiB with no unmount and no data loss.

Step 10.1: Extend the LV, then grow the filesystem

bash
lvextend -L +1G /dev/vg_logs/lv_logs
xfs_growfs /var/log/remote
df -h /var/log/remote

Order matters: extend the logical volume first, then grow the filesystem into the new space. xfs_growfs takes the mount point, not the device, and works online. XFS can grow but never shrink. (You can also do both at once with lvextend -r, but separating them keeps each step visible.)

checkpoint: df -h /var/log/remote now shows about 4 G.

Task 11: Configure NTP, hostname, and boot target

Domain 6
// deep dive

These are the small persistent settings that define a server’s identity and timekeeping. hostnamectl set-hostname writes the name to /etc/hostname so it survives reboot, unlike the transient hostname command. Adding the name to /etc/hosts lets the machine resolve itself without DNS.

Accurate time is not optional on a log server: correlating events across hosts depends on synchronised clocks, and timestamps that drift make an audit trail useless. chrony is the RHEL 9 time daemon. You point it at a source with a server line, where iburst makes the first sync happen quickly. Comment out the shipped pool and server lines first so chrony is not also averaging in defaults. Timezone is a separate concern from synchronisation: timedatectl set-timezone repoints /etc/localtime, changing how time is displayed, not how it is kept.

Finally, systemctl set-default multi-user.target sets the boot target. multi-user.target is the text, networked, no-GUI mode appropriate for a headless server; graphical.target would pull in a desktop this machine does not need.

Goal: Persistent hostname, timezone, time source, and default target.

Step 11.1: Hostname

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

hostnamectl persists the name; the /etc/hosts line lets the box resolve its own name without DNS.

Step 11.2: Timezone and time source

bash
timedatectl set-timezone America/Los_Angeles
sed -i 's/^pool/#pool/'   /etc/chrony.conf
sed -i 's/^server/#server/' /etc/chrony.conf
echo 'server time.cloudflare.com iburst' >> /etc/chrony.conf
systemctl restart chronyd
chronyc sources -v

Comment out the default sources before adding yours so chrony is not averaging in the shipped servers.

Step 11.3: Default boot target

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

multi-user.target is the text, multi-user mode, correct for a headless server.

checkpoint: hostname is logserver1.lab.example.com, the timezone is America/Los_Angeles, NTP is syncing, and the default target is multi-user.target.

Task 12: Reboot and verify everything

All Domains
// deep dive

The reboot is the real exam, not a formality. Nearly every failure on a hands-on test traces to one thing: a setting that is correct in memory right now but was never written somewhere that survives a restart.

Sort each task into two buckets as you verify. On-disk state persists by nature: the fstab entries, ACLs stored in the filesystem, the logrotate and rsyslog configs, the unit files, the SELinux labels you fixed with restorecon. Runtime state does not: a service or timer you started but never enabled, a firewall rule added without —permanent, a hostname set with the wrong command.

The verbs that catch most of it are enable (start at every boot) versus start (start now), —permanent on firewall changes, and daemon-reload so systemd sees new units before you enable them. Reboot, then walk the checklist line by line. Anything missing was runtime-only, and now you know exactly where to go back and make it stick.

bash
reboot

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

bash
df -h /var/log/remote                     # ~4G XFS mounted
df -h /var/log/archive                    # 512M XFS mounted
lvs                                       # both LVs present
ss -ulnp | grep 514                       # rsyslog UDP listening
ss -tlnp | grep 514                       # rsyslog TCP listening
firewall-cmd --list-ports                 # 514/udp 514/tcp
/usr/local/bin/disk_alert.sh              # runs, returns OK/ALERT
crontab -l                                # both cron jobs present
systemctl is-active log-monitor.timer     # active
systemctl is-active rsyslog               # active
systemctl is-active cups                  # inactive
getfacl /var/log/archive                  # ACLs intact
podman ps                                 # logviewer running
systemctl is-active container-logviewer   # active
hostname                                  # logserver1.lab.example.com
timedatectl | grep "Time zone"            # America/Los_Angeles
systemctl get-default                     # multi-user.target

If anything failed after reboot

SymptomLikely causeFix
/var/log/remote or /var/log/archive not mountedBad UUID or fstab typoBoot to emergency mode, fix /etc/fstab, reboot
rsyslog not listening on 514imudp/imtcp lines not uncommented, or config errorUncomment the module and input lines, run rsyslogd -N1, restart
Remote logs not written, permission deniedSELinux label wrong on the fresh LVrestorecon -Rv /var/log/remote
Firewall blocks 514 after rebootRule added without —permanentRe-add with —permanent, then —reload
Timer never firesEnabled the service instead of the timer, or skipped daemon-reloadsystemctl daemon-reload && systemctl enable —now log-monitor.timer
Container missing after rebootService not enabled, or the manual container was left in placepodman rm -f logviewer, then systemctl enable —now container-logviewer
lvextend failsNo free space in the VGCheck vgs; the backing disk must be large enough
Hostname revertedUsed hostname instead of hostnamectlhostnamectl set-hostname logserver1.lab.example.com
Common MistakesThe traps that cost points, and the ones that fail the whole exam.

Task 1: Storage

MistakeWhat goes wrongFix
Not leaving enough free space in the VG for Task 10lvextend fails, not enough free extentsWhen creating VGs, account for future growth
Mounting at existing non-empty directoriesExisting files become hidden under the mountAlways mount to empty directories
Forgetting mount -a after editing fstabMounts don’t activate until rebootAlways run mount -a and check for errors

Task 2: rsyslog remote logging

MistakeWhat goes wrongFix
Forgetting to uncomment the imudp and imtcp module linesrsyslog doesn’t listen on port 514Both the module(load=…) and input(…) lines must be uncommented
Opening firewall port without —permanentPort closes after rebootAlways use —permanent then —reload
Not restarting rsyslog after config changesNew config not loadedsystemctl restart rsyslog after every change
Placing the template rule before module loadingrsyslog may fail to parse configModule load lines must come before template rules

Task 3: logrotate

MistakeWhat goes wrongFix
Using rotate 30 without dailyKeeps 30 rotations but frequency is wrongAlways specify both frequency and count
Forgetting notifemptyEmpty log files get rotated unnecessarilyAdd notifempty to skip empty files
Postrotate script not ending with endscriptlogrotate config parse errorAlways close postrotate blocks with endscript
Testing with —force instead of —debugActually rotates logs during testingUse —debug to test without making changes

Task 4: Disk alert script

MistakeWhat goes wrongFix
Not setting a default for the threshold argumentScript fails if run without argumentUse ${1:-80} to default to 80 if no arg given
Using echo for logging instead of appending to log fileNo persistent record of alertsAlways >> output to the log file
Not making the script executableCron runs silently and does nothingchmod +x immediately after creating the script

Task 5: Log summary script

MistakeWhat goes wrongFix
Using grep -c without -iMisses Error, ERROR, error etc.Always use -i for case-insensitive counting
Not handling missing log filesScript errors out if no .log files existAdd [ -f &quot;$f&quot; ] \</td><td>\</td><td>continue guard
Overwriting the summary file each runLose previous summariesUse date-stamped filenames for each run

Task 6: Scheduling

MistakeWhat goes wrongFix
Forgetting daemon-reload before enabling timersystemd doesn’t see the new unitAlways daemon-reload after creating unit files
Enabling the service instead of the timerService runs once then stopsEnable and start the .timer unit
Wrong cron syntax for every 30 minutesJob doesn’t run when expected*/30 * * * * means every 30 minutes

Task 7: Services

MistakeWhat goes wrongFix
Using systemctl stop without disableService stops now but restarts on rebootAlways use disable —now to stop and prevent reboot start
Not checking failed units after changesHidden failures go unnoticedAlways run systemctl list-units —state=failed after service changes

Task 8: ACLs

MistakeWhat goes wrongFix
Setting ACL on directory but not default ACLNew files don’t inherit permissionsUse both setfacl -m and setfacl -d -m
Creating service accounts with login shellsSecurity risk, accounts can be logged intoAlways use -s /sbin/nologin for service accounts
Forgetting chown before setfaclACLs set on wrong owner/groupSet ownership first, then ACLs

Task 9: Container

MistakeWhat goes wrongFix
Forgetting :Z on volume mountSELinux denies container access to host directoryAlways use :Z when mounting host dirs on RHEL
Not using —new with podman generate systemdUnit ties to specific container ID, breaks after recreationAlways use —new flag
Forgetting daemon-reload after placing unit filesystemd can’t find the new servicesystemctl daemon-reload before enable/start

Task 10: Online resize

MistakeWhat goes wrongFix
Using resize2fs on an XFS filesystemCommand fails, wrong toolXFS uses xfs_growfs /mountpoint not resize2fs
Trying to shrink an XFS filesystemXFS cannot be shrunk, everXFS only grows, never shrinks
Running xfs_growfs before lvextendNothing to grow intoAlways extend the LV first, then grow the filesystem

General Mistakes That Fail the Exam

MistakeImpact
Not rebooting to verify persistencePass tasks manually, fail the reboot check
Using xfs_growfs device path instead of mount pointCommand may fail or grow wrong filesystem
Forgetting —permanent on firewall rulesPort access lost after reboot
Not validating rsyslog config before restartrsyslog fails silently with bad config, use rsyslogd -N1 to test
Creating logrotate config with syntax errorsLogs never rotate, disk fills up
next post
RHCSA EX200 Project 4