19 — Exercises & Projects
Apply everything from chapters 1–18 in real-world projects. These exercises progress from focused drills to a full capstone. Each project lists requirements, hints, and a verification checklist.
Project 1 — System Inventory Script
Write a script that produces a system inventory report. Covers chapters 02–05 (shell, text processing, files, permissions).
Requirements: OS, kernel version, architecture, CPU model and core count, total and available memory, disk usage per filesystem, top 5 processes by CPU and memory, listening TCP ports.
#!/usr/bin/env bash
set -euo pipefail
echo "=== System Inventory ==="
echo "Date: $(date)"
echo "Host: $(hostname)"
echo "OS: $(grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d '"')"
echo "Kernel: $(uname -r)"
echo "Arch: $(uname -m)"
echo
echo "=== CPU ==="
echo "Model: $(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2 | xargs)"
echo "Cores: $(nproc)"
echo
echo "=== Memory ==="
free -h | awk '/Mem/ {print "Total:", $2, " Available:", $7}'
echo
echo "=== Disk Usage ==="
df -h | awk 'NR==1 || /^\/dev/' | column -t
echo
echo "=== Top 5 by CPU ==="
ps aux --sort=-%cpu | head -6 | awk '{printf "%-10s %5s %5s %s\n", $1, $3, $4, $11}'
echo
echo "=== Top 5 by Memory ==="
ps aux --sort=-%mem | head -6 | awk '{printf "%-10s %5s %5s %s\n", $1, $3, $4, $11}'
echo
echo "=== Listening TCP Ports ==="
ss -tlnp | awk 'NR>1 {print $4, $6}' | column -t
Verification:
- Output is formatted in readable tables.
- Script runs with
set -euo pipefailwithout errors. - Works on both Debian and RHEL (portable commands).
Project 2 — Log Analysis Pipeline
Build a pipeline to analyze web server logs. Covers chapters 05 (text processing) and 12 (logging).
Requirements: Top 10 requesting IPs, top 10 requested paths, top 10 HTTP status codes, count of 4xx and 5xx errors, total bandwidth.
#!/usr/bin/env bash
set -euo pipefail
LOG="${1:-/var/log/nginx/access.log}"
[[ -f "$LOG" ]] || { echo "Log not found: $LOG" >&2; exit 1; }
echo "=== Log Analysis: $LOG ==="
echo "Total requests: $(wc -l < "$LOG")"
echo
echo "=== Top 10 IPs ==="
awk '{print $1}' "$LOG" | sort | uniq -c | sort -rn | head -10 | awk '{printf "%6d %s\n", $1, $2}'
echo
echo "=== Top 10 Paths ==="
awk -F'"' '{print $2}' "$LOG" | awk '{print $2}' | sort | uniq -c | sort -rn | head -10 | awk '{printf "%6d %s\n", $1, $2}'
echo
echo "=== Status Codes ==="
awk -F'"' '{print $3}' "$LOG" | awk '{print $1}' | sort | uniq -c | sort -rn
echo
echo "=== Error Summary ==="
awk -F'"' '{print $3}' "$LOG" | awk '{print $1}' | awk '$1 >= 400 && $1 < 500 {c4++} $1 >= 500 {c5++} END{print "4xx:", c4+0; print "5xx:", c5+0}'
echo
echo "=== Bandwidth ==="
awk '{sum += $10} END{printf "Total: %.2f MB\n", sum/1024/1024}' "$LOG"
Verification:
- Top IPs/paths/status codes are correct (verify with manual
grep | wc). -
sortbeforeuniq -c(common bug: forgetting to sort). - Bandwidth is in MB (converted from bytes).
Project 3 — User & Group Audit
Audit user accounts and sudo access. Covers chapter 07.
#!/usr/bin/env bash
set -euo pipefail
echo "=== User Audit ==="
echo
echo "Regular users (UID ≥ 1000):"
awk -F: '$3 >= 1000 && $3 < 65534 {printf " %-15s UID:%s Shell:%s\n", $1, $3, $7}' /etc/passwd
echo
echo "Users with login shells:"
awk -F: '$7 !~ /nologin|false$/ {printf " %-15s %s\n", $1, $7}' /etc/passwd
echo
echo "Sudo users:"
getent group sudo 2>/dev/null || getent group wheel
echo
echo "Password aging (regular users):"
for user in $(awk -F: '$3 >= 1000 && $3 < 65534 {print $1}' /etc/passwd); do
echo " --- $user ---"
sudo chage -l "$user" 2>/dev/null | sed 's/^/ /'
done
echo
echo "Accounts with no password or locked:"
sudo awk -F: '($2 == "!" || $2 == "*" || $2 == "!!") {print " " $1 ": " $2}' /etc/shadow
Verification:
- All regular users listed.
- Sudo group membership correct (cross-check with
id <user>). - Locked accounts flagged correctly (
passwd -S).
Project 4 — Hardened Web Server
Set up an nginx web server with full hardening. Covers chapters 11, 14, 10.
#!/usr/bin/env bash
set -euo pipefail
# 1. Install
sudo apt update && sudo apt install -y nginx ufw fail2ban
# 2. Firewall
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
# 3. SSH hardening (verify key auth first!)
sudo sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sshd -t && sudo systemctl reload ssh
# 4. fail2ban
sudo systemctl enable --now fail2ban
# 5. TLS cert (self-signed — use certbot for real)
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/selfsigned.key \
-out /etc/ssl/certs/selfsigned.crt \
-subj "/CN=localhost"
# 6. nginx config with security headers
sudo tee /etc/nginx/sites-available/hardened <<'EOF'
server {
listen 80;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
ssl_certificate /etc/ssl/certs/selfsigned.crt;
ssl_certificate_key /etc/ssl/private/selfsigned.key;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
root /var/www/html;
index index.html;
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/hardened /etc/nginx/sites-enabled/hardened
sudo nginx -t && sudo systemctl reload nginx
# 7. Verify
curl -kI https://localhost/
ss -tlnp | grep -E ':80|:443'
sudo ufw status
Verification:
-
curl -kI https://localhost/returns 200. -
ufw statusshows only 22/80/443. -
fail2ban-client status sshdshows the jail active. - HTTP redirects to HTTPS.
Project 5 — Automated Backup with systemd Timers
Create a backup system using systemd timers (replacing cron). Covers chapters 09, 11.
#!/usr/bin/env bash
# /opt/backup/backup.sh
set -euo pipefail
SRC="${1:-/home}"
DST="${2:-/backup}"
KEEP=7
mkdir -p "$DST"
timestamp=$(date +%Y%m%d_%H%M%S)
archive="$DST/backup_${timestamp}.tar.gz"
echo "Backing up $SRC to $archive"
tar -czf "$archive" -C "$(dirname "$SRC")" "$(basename "$SRC")"
# Verify integrity
if ! gzip -t "$archive"; then
echo "ERROR: Backup $archive is corrupt" >&2
exit 1
fi
# Rotate old backups
mapfile -t backups < <(ls -1 "$DST"/backup_*.tar.gz 2>/dev/null | sort -r)
if (( ${#backups[@]} > KEEP )); then
for old in "${backups[@]:KEEP}"; do
echo "Removing old backup: $old"
rm -f "$old"
done
fi
echo "Backup complete: $archive ($(du -h "$archive" | cut -f1))"
# /etc/systemd/system/backup.service
sudo tee /etc/systemd/system/backup.service <<'EOF'
[Unit]
Description=Daily backup
After=network.target
[Service]
Type=oneshot
ExecStart=/opt/backup/backup.sh /home /backup
Nice=19
IOSchedulingClass=idle
EOF
# /etc/systemd/system/backup.timer
sudo tee /etc/systemd/system/backup.timer <<'EOF'
[Unit]
Description=Daily backup at 2 AM
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers | grep backup
Verification:
-
systemctl start backup.servicecreates a backup. -
journalctl -u backup.serviceshows log output. - Old backups are rotated (keep 7).
- Corrupt backup detection works (test with
gzip -t).
Project 6 — Process & Resource Monitor
Build a monitoring script that alerts on resource thresholds. Covers chapters 06, 15, 12.
#!/usr/bin/env bash
# /opt/monitor/monitor.sh
set -euo pipefail
CPU_THRESHOLD=80
DISK_THRESHOLD=90
MEM_THRESHOLD=90
CRITICAL_PROCS=("sshd" "nginx")
while true; do
# CPU (load avg / cores * 100)
load=$(awk '{print $1}' /proc/loadavg)
cores=$(nproc)
cpu_pct=$(awk "BEGIN{printf \"%.0f\", ($load / $cores) * 100}")
if (( cpu_pct > CPU_THRESHOLD )); then
logger -t monitor -p user.warn "CPU load high: ${cpu_pct}% (load $load, $cores cores)"
fi
# Disk
while read -r line; do
usage=$(echo "$line" | awk '{print $5}' | tr -d '%')
part=$(echo "$line" | awk '{print $6}')
if (( usage > DISK_THRESHOLD )); then
logger -t monitor -p user.warn "Disk $part at ${usage}%"
fi
done < <(df -h | awk 'NR>1 && /^\/dev/')
# Memory
mem_avail=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
mem_total=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
mem_pct=$(awk "BEGIN{printf \"%.0f\", (1 - $mem_avail/$mem_total) * 100}")
if (( mem_pct > MEM_THRESHOLD )); then
logger -t monitor -p user.warn "Memory usage high: ${mem_pct}%"
fi
# Critical processes
for proc in "${CRITICAL_PROCS[@]}"; do
if ! pgrep -x "$proc" >/dev/null; then
logger -t monitor -p user.err "Critical process $proc is NOT running!"
fi
done
sleep 60
done
sudo tee /etc/systemd/system/monitor.service <<'EOF'
[Unit]
Description=Resource Monitor
After=network.target
[Service]
ExecStart=/opt/monitor/monitor.sh
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now monitor
journalctl -t monitor -f
Verification:
- High load (use
stress --cpu $(nproc)) triggers a log entry. - Stopping nginx triggers "not running" alert.
-
journalctl -t monitorshows all alerts.
Project 7 — LVM + RAID Storage Setup
Configure a resilient storage setup. Covers chapter 09.
#!/usr/bin/env bash
set -euo pipefail
# 1. Create RAID 1
sudo mdadm --create /dev/md0 --level=1 --raid-devices=2 /dev/sdb /dev/sdc
cat /proc/mdstat
# 2. LVM on the RAID
sudo pvcreate /dev/md0
sudo vgcreate vg_data /dev/md0
sudo lvcreate -L 50G -n lv_data vg_data
# 3. Filesystem + mount
sudo mkfs.ext4 /dev/vg_data/lv_data
sudo mkdir -p /mnt/data
echo "/dev/vg_data/lv_data /mnt/data ext4 defaults,noatime 0 2" | sudo tee -a /etc/fstab
sudo mount -a
# 4. Snapshot + backup
sudo lvcreate -L 5G -s -n lv_data_snap /dev/vg_data/lv_data
sudo mount -o ro /dev/vg_data/lv_data_snap /mnt/snap
tar -czf /tmp/data-backup.tar.gz -C /mnt/snap .
sudo umount /mnt/snap
sudo lvremove -f /dev/vg_data/lv_data_snap
# 5. Simulate disk failure
sudo mdadm --fail /dev/md0 /dev/sdb
sudo mdadm --remove /dev/md0 /dev/sdb
cat /proc/mdstat # shows degraded
# Save RAID config
sudo mdadm --detail --scan | sudo tee -a /etc/mdadm/mdadm.conf
sudo update-initramfs -u
Verification:
-
lsblkshows the RAID + LVM stack. -
cat /proc/mdstatshows the RAID status. - Snapshot was created and removed cleanly.
-
mdadm --failshowed the array going degraded.
Project 8 — Containerized App Stack
Deploy a multi-container app stack. Covers chapter 17.
version: "3.8"
services:
db:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
volumes:
- dbdata:/var/lib/postgresql/data
networks:
- appnet
healthcheck:
test: ["CMD", "pg_isready", "-U", "appuser"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
restart: unless-stopped
backend:
build: ./backend
environment:
DATABASE_URL: postgres://appuser:secret@db:5432/myapp
depends_on:
db:
condition: service_healthy
networks:
- appnet
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"
restart: unless-stopped
user: "1000:1000"
web:
image: nginx:1.27
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
networks:
- appnet
restart: unless-stopped
volumes:
dbdata:
networks:
appnet:
docker compose up -d
docker compose ps
docker compose logs -f
docker compose down # stop + remove
docker compose down -v # also remove volumes (data!)
Verification:
-
docker compose psshows all services healthy. -
curl http://localhost:8080returns the app. - Backend can reach
db:5432(DNS resolution works). - Data persists across
docker compose down+up(volume).
Capstone — Build a Production Linux Server
Combine everything into a fully configured production server.
Requirements:
- Base system: fresh install, fully updated.
- Users: one admin user with sudo (key-only SSH), one service account.
- Hardening: UFW (22/80/443), SSH hardened, fail2ban, SELinux/AppArmor enforcing.
- Services (systemd): nginx (web), PostgreSQL (database), a custom app.
- Storage: LVM for
/data, ext4 withnoatime. - Backups: systemd timer daily, 7-day retention, integrity verified.
- Monitoring: resource monitor (Project 6), logs to journald + forwarded to a central server.
- Logging: journald persistent, logrotate configured.
- Performance:
swappiness=10,noatimemounts,LimitNOFILE/MemoryMaxon the app. - Containers: the custom app runs in a container (rootless via Podman), managed by systemd.
- Documentation: a README documenting the setup, recovery procedures, and credential locations.
Verification (full checklist):
-
apt upgrade/dnf upgradeshows nothing to update. -
ufw statusshows only 22/80/443. -
sshd -T | grep permitrootloginshowsno. -
fail2ban-client status sshdis active. -
getenforce(RHEL) showsEnforcingor AppArmor profiles active. -
systemctl list-units --state=failedshows nothing. -
journalctl --disk-usageis within limits. -
df -hshows adequate free space. -
docker ps(orpodman ps) shows the app container running. -
curl -I https://localhostreturns 200. - Backup timer is active (
systemctl list-timers | grep backup). - Monitor is running (
systemctl status monitor). - Logs are persistent (
ls /var/log/journal/). -
sysctl vm.swappinessshows 10.
💡 Tips & Tricks
- Idiom: every project's verification checklist doubles as a runbook — when Project 4's hardened server goes down at 3am,
curl -kI https://localhost/,ufw status, andfail2ban-client status sshdare the fastest first three checks, in that order, because they narrow "network," "firewall," and "auth" independently. - Debug:
systemctl list-units --state=failed(used in the Capstone) is the single fastest way to see everything currently broken on a box — run it before diving into individualjournalctl -ucalls, since it tells you what to investigate instead of guessing. - Performance: Project 6's monitor script computes CPU from
/proc/loadavgrather than parsingtop/psoutput — reading/procdirectly avoids spawning a subprocess per sample, which matters when the check loop runs every 60 seconds indefinitely as a long-lived systemd service. - Safety: Project 5's
gzip -t "$archive"integrity check after every backup is the difference between "we have backups" and "we have backups that restore" — untested backups are a liability that looks identical to a working backup strategy until the day you need it. - Idiom: Project 7's LVM snapshot-then-backup-then-remove pattern (
lvcreate -s→ mount read-only →tar→umount→lvremove) lets you back up a live, mounted filesystem in a crash-consistent state without stopping the service using it — the snapshot freezes a point-in-time view via copy-on-write.
⚠️ Edge Cases & Gotchas
awk "BEGIN{printf ...}"in Project 6 embeds shell variables directly into the AWK program string:cpu_pct=$(awk "BEGIN{printf ... ($load / $cores) * 100}")interpolates$load/$coresvia bash before AWK ever sees the program — if/proc/loadavgever produced a non-numeric or empty value (e.g., during a container's very first tick before the file is populated), the resulting AWK program is syntactically broken and errors instead of failing gracefully with a sentinel value.- Project 4 disables
PasswordAuthenticationbefore confirming key-based login actually works: the script comments "verify key auth first!" for a reason — running thesedhardening steps over an SSH session that itself relies on password auth, without a second confirmed key-based session open, is how you lock yourself out of a remote box with no console access. ss -tlnp(Projects 1 and 4) requires root to show the owning process: run unprivileged, thep(process) column comes back empty rather than erroring — a report generated by a non-root cron job will silently omit the process-per-port mapping without any visible failure.- Project 7's RAID failure simulation (
mdadm --fail/--remove) is destructive and irreversible on that disk: this is meant for a lab/test VM only — running it against/dev/sdbon a system where that device also holds unrelated data (common when experimenting on a box that isn't dedicated to the exercise) destroys that data with no confirmation prompt. - Docker Compose's
depends_on: condition: service_healthy(Project 8) only gates container start order, not application readiness inside the dependent container:backendwon't start untildb's healthcheck passes, but ifbackenditself opens its DB connection before its own app server is ready to accept traffic,web'sdepends_on: - backend(a plain dependency, no healthcheck) can still route traffic to a backend that isn't actually serving yet.
🧠 Quick Quiz
Project 6's monitor loop logs a disk-usage warning like this:
while read -r line; do
usage=$(echo "$line" | awk '{print $5}' | tr -d '%')
part=$(echo "$line" | awk '{print $6}')
if (( usage > DISK_THRESHOLD )); then
logger -t monitor -p user.warn "Disk $part at ${usage}%"
fi
done < <(df -h | awk 'NR>1 && /^\/dev/')
A teammate suggests simplifying it to df -h | awk 'NR>1 && /^\/dev/' | while read -r line; do ... done (a plain pipe instead of process substitution) to make it "more readable." What breaks if any of the loop body's variables (usage, part) were meant to be visible after the loop ends, and why does the process-substitution version avoid that problem?
Answer
A plain pipe (cmd | while read ...) runs the while loop in a subshell, because every command in a pipeline gets its own subshell in bash — any variables set inside the loop (including usage and part) vanish when the loop exits, since they only existed in the child subshell's memory. The process-substitution form (done < <(df -h | ...)) keeps the while loop in the current shell — only the df/awk producer runs in a subshell (via <(...)), so variables assigned inside the loop persist afterward. Project 6 doesn't rely on this (each iteration is self-contained), but it's the exact reason this idiom — not a plain pipe — appears throughout this course whenever a loop needs to accumulate state (a running total, an array built line-by-line) across iterations.