Configure a Centralized Log Management Server
An exam-level RHEL 9 capstone you attempt first, then a deep-dive walkthrough.
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.
/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.Task 1: Provision dedicated log storage
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
Configure rsyslog to:
- Listen for incoming UDP syslog messages on port 514
- Listen for incoming TCP syslog messages on port 514
- Write incoming logs from each remote host into a separate file:
/var/log/remote/%HOSTNAME%.log - Restart rsyslog and verify it is listening on port 514
Open port 514 (both UDP and TCP) permanently in firewalld.
Task 3: Configure rsyslog log rotation
Create a logrotate configuration file at /etc/logrotate.d/remote-logs that:
- Rotates all files matching
/var/log/remote/*.log - Rotates daily
- Keeps 30 days of logs
- Compresses rotated logs
- Skips rotation if the log file is empty
- Runs
systemctl restart rsyslogafter rotation as a postrotate script
Task 4: Write a disk usage alert script
Create an executable script at /usr/local/bin/disk_alert.sh that:
- Accepts a threshold percentage as its first argument (default to 80 if not provided)
- Checks disk usage on
/var/log/remoteand/var/log/archive - For each filesystem, if usage is at or above the threshold, prints:
ALERT: /path is N% full - If usage is below threshold, prints:
OK: /path is N% full - Logs all output with a timestamp to
/var/log/disk_alerts.log - Exits with code 1 if any filesystem is above threshold, 0 if all are OK
Task 5: Write a log summary script
Create an executable script at /usr/local/bin/log_summary.sh that:
- Accepts a hostname as its first argument
- If no argument is given, summarizes all files in
/var/log/remote/ - For each log file, counts and prints the number of lines containing
error,warning, andcritical(case-insensitive) - Outputs in the format:
hostname: errors=N warnings=N critical=N - Saves the summary to
/var/log/summary_YYYYMMDD.txt
Task 6: Schedule monitoring tasks
- Schedule
disk_alert.shto run every 30 minutes via a root crontab entry - Schedule
log_summary.shto run every day at midnight via a root crontab entry - Create a systemd service
/etc/systemd/system/log-monitor.servicethat runsdisk_alert.sh 90at boot - Create a systemd timer
/etc/systemd/system/log-monitor.timerthat triggerslog-monitor.serviceevery hour - Enable and start the timer
Task 7: Manage and harden services
Ensure the following service states and verify each survives a reboot:
rsyslog: active and enabledfirewalld: active and enabledchronyd: active and enabledcups: stopped and disabledavahi-daemon: stopped and disabled
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
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:
- Ownership:
root:logadmins - Permissions:
750 - ACL: give
logadmin1read/write/execute,logadmin2read/execute only - Default ACL: new files inherit read/write for
logadminsgroup
Verify with getfacl /var/log/archive.
Task 9: Deploy a container-based log viewer
As root, pull registry.access.redhat.com/ubi9/ubi:latest. Run a detached container named logviewer that:
- Mounts
/var/log/remoteon the host to/logsinside the container (use:Zfor SELinux) - Maps host port
9090to container port80 - Sets environment variable
LOG_DIR=/logs - Runs
sleep infinity
Generate a systemd unit file, place it in /etc/systemd/system/, enable it so it starts at boot.
Task 10: Extend log storage online
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
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
Reboot the system and confirm every configuration survived. Use the verification checklist in the Outcomes section.
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:
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:
firewall-cmd --list-ports # 514/udp 514/tcp
Verify logrotate config:
logrotate --debug /etc/logrotate.d/remote-logs # Should show rotation would occur without errors
Scripts (Tasks 4, 5)
Test disk alert script:
/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:
/usr/local/bin/log_summary.sh ls /var/log/summary_*.txt cat /var/log/summary_20260420.txt
Scheduling (Task 6)
Verify cron jobs:
crontab -l # */30 * * * * /usr/local/bin/disk_alert.sh # 0 0 * * * /usr/local/bin/log_summary.sh
Verify systemd timer:
systemctl is-active log-monitor.timer # active systemctl list-timers | grep log-monitor # log-monitor.timer ... 1h log-monitor.service
Services (Task 7)
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)
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)
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
Task 1: Provision dedicated log storage
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
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
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
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.
df, and ls -Zd /var/log/remote shows var_log_t.Task 2: Configure rsyslog to receive remote logs
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
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
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
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
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.
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
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 postrotate … endscript 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
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
}
EOFdaily 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
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.
logrotate —debug runs with no syntax errors and reports it considered /var/log/remote/*.log.Task 4: Write a disk usage alert script
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
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.shTwo 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
/usr/local/bin/disk_alert.sh 80 echo "exit code: $?" cat /var/log/disk_alerts.log
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
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
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.shTwo 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
/usr/local/bin/log_summary.sh cat /var/log/summary_*.txt
host: errors=N warnings=N critical=N, saved to the date-stamped file.Task 6: Schedule monitoring tasks
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
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
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.
crontab -l shows both jobs, and systemctl is-active log-monitor.timer is active.Task 7: Manage and harden services
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
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
systemctl list-units --state=failed > /root/failed_units.txt cat /root/failed_units.txt
/root/failed_units.txt exists.Task 8: Configure log archiving with ACLs
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
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
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
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.
getfacl shows the two named user ACLs and the default logadmins group entry.Task 9: Deploy a container-based log viewer
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
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
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
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.
podman ps shows logviewer running, and container-logviewer.service is active and enabled.Task 10: Extend log storage online
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
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.)
df -h /var/log/remote now shows about 4 G.Task 11: Configure NTP, hostname, and boot target
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
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
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
systemctl set-default multi-user.target systemctl get-default
multi-user.target is the text, multi-user mode, correct for a headless server.
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
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.
reboot
After it comes back, walk the checklist one line at a time:
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
| Symptom | Likely cause | Fix |
|---|---|---|
/var/log/remote or /var/log/archive not mounted | Bad UUID or fstab typo | Boot to emergency mode, fix /etc/fstab, reboot |
| rsyslog not listening on 514 | imudp/imtcp lines not uncommented, or config error | Uncomment the module and input lines, run rsyslogd -N1, restart |
| Remote logs not written, permission denied | SELinux label wrong on the fresh LV | restorecon -Rv /var/log/remote |
| Firewall blocks 514 after reboot | Rule added without —permanent | Re-add with —permanent, then —reload |
| Timer never fires | Enabled the service instead of the timer, or skipped daemon-reload | systemctl daemon-reload && systemctl enable —now log-monitor.timer |
| Container missing after reboot | Service not enabled, or the manual container was left in place | podman rm -f logviewer, then systemctl enable —now container-logviewer |
lvextend fails | No free space in the VG | Check vgs; the backing disk must be large enough |
| Hostname reverted | Used hostname instead of hostnamectl | hostnamectl set-hostname logserver1.lab.example.com |
Task 1: Storage
| Mistake | What goes wrong | Fix |
|---|---|---|
| Not leaving enough free space in the VG for Task 10 | lvextend fails, not enough free extents | When creating VGs, account for future growth |
| Mounting at existing non-empty directories | Existing files become hidden under the mount | Always mount to empty directories |
Forgetting mount -a after editing fstab | Mounts don’t activate until reboot | Always run mount -a and check for errors |
Task 2: rsyslog remote logging
| Mistake | What goes wrong | Fix |
|---|---|---|
Forgetting to uncomment the imudp and imtcp module lines | rsyslog doesn’t listen on port 514 | Both the module(load=…) and input(…) lines must be uncommented |
Opening firewall port without —permanent | Port closes after reboot | Always use —permanent then —reload |
| Not restarting rsyslog after config changes | New config not loaded | systemctl restart rsyslog after every change |
| Placing the template rule before module loading | rsyslog may fail to parse config | Module load lines must come before template rules |
Task 3: logrotate
| Mistake | What goes wrong | Fix |
|---|---|---|
Using rotate 30 without daily | Keeps 30 rotations but frequency is wrong | Always specify both frequency and count |
Forgetting notifempty | Empty log files get rotated unnecessarily | Add notifempty to skip empty files |
Postrotate script not ending with endscript | logrotate config parse error | Always close postrotate blocks with endscript |
Testing with —force instead of —debug | Actually rotates logs during testing | Use —debug to test without making changes |
Task 4: Disk alert script
| Mistake | What goes wrong | Fix |
|---|---|---|
| Not setting a default for the threshold argument | Script fails if run without argument | Use ${1:-80} to default to 80 if no arg given |
Using echo for logging instead of appending to log file | No persistent record of alerts | Always >> output to the log file |
| Not making the script executable | Cron runs silently and does nothing | chmod +x immediately after creating the script |
Task 5: Log summary script
| Mistake | What goes wrong | Fix |
|---|---|---|
Using grep -c without -i | Misses Error, ERROR, error etc. | Always use -i for case-insensitive counting |
| Not handling missing log files | Script errors out if no .log files exist | Add [ -f "$f" ] \</td><td>\</td><td>continue guard |
| Overwriting the summary file each run | Lose previous summaries | Use date-stamped filenames for each run |
Task 6: Scheduling
| Mistake | What goes wrong | Fix |
|---|---|---|
Forgetting daemon-reload before enabling timer | systemd doesn’t see the new unit | Always daemon-reload after creating unit files |
| Enabling the service instead of the timer | Service runs once then stops | Enable and start the .timer unit |
| Wrong cron syntax for every 30 minutes | Job doesn’t run when expected | */30 * * * * means every 30 minutes |
Task 7: Services
| Mistake | What goes wrong | Fix |
|---|---|---|
Using systemctl stop without disable | Service stops now but restarts on reboot | Always use disable —now to stop and prevent reboot start |
| Not checking failed units after changes | Hidden failures go unnoticed | Always run systemctl list-units —state=failed after service changes |
Task 8: ACLs
| Mistake | What goes wrong | Fix |
|---|---|---|
| Setting ACL on directory but not default ACL | New files don’t inherit permissions | Use both setfacl -m and setfacl -d -m |
| Creating service accounts with login shells | Security risk, accounts can be logged into | Always use -s /sbin/nologin for service accounts |
Forgetting chown before setfacl | ACLs set on wrong owner/group | Set ownership first, then ACLs |
Task 9: Container
| Mistake | What goes wrong | Fix |
|---|---|---|
Forgetting :Z on volume mount | SELinux denies container access to host directory | Always use :Z when mounting host dirs on RHEL |
Not using —new with podman generate systemd | Unit ties to specific container ID, breaks after recreation | Always use —new flag |
Forgetting daemon-reload after placing unit file | systemd can’t find the new service | systemctl daemon-reload before enable/start |
Task 10: Online resize
| Mistake | What goes wrong | Fix |
|---|---|---|
Using resize2fs on an XFS filesystem | Command fails, wrong tool | XFS uses xfs_growfs /mountpoint not resize2fs |
| Trying to shrink an XFS filesystem | XFS cannot be shrunk, ever | XFS only grows, never shrinks |
Running xfs_growfs before lvextend | Nothing to grow into | Always extend the LV first, then grow the filesystem |
General Mistakes That Fail the Exam
| Mistake | Impact |
|---|---|
| Not rebooting to verify persistence | Pass tasks manually, fail the reboot check |
Using xfs_growfs device path instead of mount point | Command may fail or grow wrong filesystem |
Forgetting —permanent on firewall rules | Port access lost after reboot |
| Not validating rsyslog config before restart | rsyslog fails silently with bad config, use rsyslogd -N1 to test |
| Creating logrotate config with syntax errors | Logs never rotate, disk fills up |