How to Send Yourself a Weekly Server Report (One Script, Every Monday, Everything That Matters)

Tutorial 39's monthly review is seven commands you have to remember to run. Alerts (35, 93, 104) tell you when something breaks. What's missing is the quiet middle: the slow trends and the small things that aren't alarms yet — disk creeping up, a container that restarted twice, a certificate with three weeks left, three failed SSH logins from your own IP. A script that collects all of it into one message every Monday morning takes an hour to write and then runs for years.
Quick reference
| Section | Command behind it |
|---|---|
| Uptime, load, reboots | uptime, last reboot |
| Disk | df -h with a warning over 80% |
| Memory and swap | free -h |
| Pending updates, reboot required | apt list --upgradable, /var/run/reboot-required |
| Failed services | systemctl --failed |
| Containers | docker ps with restart counts |
| Logins and failures | last -n, lastb, journalctl -u ssh |
| Backups | tail of backup.log / restic snapshots |
| Drives | smartctl -H per drive |
| Certificates | certbot certificates |
| Delivery | mail (49), ntfy (93) or Telegram (118) |
1. The script
sudo nano /usr/local/bin/weekly-report.sh
#!/bin/bash
set -uo pipefail
H=$(hostname)
R=$(mktemp)
section() { printf '\n=== %s ===\n' "$1" >> "$R"; }
section "System"
{ uptime; echo "Last reboots:"; last reboot -n 3 | head -3; } >> "$R"
section "Disk"
df -h --output=target,size,used,pcent -x tmpfs -x devtmpfs -x overlay | awk 'NR==1 || $4+0 >= 0' >> "$R"
df --output=target,pcent -x tmpfs -x devtmpfs -x overlay | awk 'NR>1 && $2+0 > 80 {print "WARNING: " $1 " at " $2}' >> "$R"
section "Memory"
free -h >> "$R"
section "Updates"
apt list --upgradable 2>/dev/null | grep -c upgradable | sed 's/^/Packages upgradable: /' >> "$R"
[ -f /var/run/reboot-required ] && echo "REBOOT REQUIRED" >> "$R"
grep -h "Installed\|Upgrade" /var/log/unattended-upgrades/unattended-upgrades.log 2>/dev/null | tail -n 3 >> "$R"
section "Failed services"
systemctl --failed --no-legend >> "$R"
[ -z "$(systemctl --failed --no-legend)" ] && echo "none" >> "$R"
section "Containers"
docker ps -a --format '{{.Names}}\t{{.Status}}' 2>/dev/null | column -t >> "$R"
for c in $(docker ps -q 2>/dev/null); do
n=$(docker inspect -f '{{.Name}} {{.RestartCount}}' "$c"); [ "${n##* }" -gt 0 ] && echo "RESTARTED: $n" >> "$R"
done
section "Logins (7 days)"
last -s -7days -a | grep -v "^$\|wtmp\|reboot" | awk '{print $1, $4, $5, $6, $NF}' | sort | uniq -c >> "$R"
echo "SSH failures: $(journalctl -u ssh --since '7 days ago' 2>/dev/null | grep -c 'Failed password\|Invalid user')" >> "$R"
journalctl -u ssh --since '7 days ago' 2>/dev/null | grep Accepted | awk '{print $NF}' | sort | uniq -c | sed 's/^/Accepted keys: /' >> "$R"
section "Backups"
tail -n 3 /var/log/backup.log 2>/dev/null >> "$R"
[ -f /root/.restic.env ] && { source /root/.restic.env; restic snapshots --latest 1 --compact 2>/dev/null | tail -n 4 >> "$R"; }
section "Drives"
for d in $(lsblk -dno NAME,TYPE | awk '$2=="disk"{print $1}'); do
echo "$d: $(smartctl -H /dev/$d 2>/dev/null | grep -i 'overall\|result' | sed 's/.*: //') $(smartctl -A /dev/$d 2>/dev/null | awk '/Reallocated_Sector|Current_Pending/ {printf "%s=%s ", $2, $10}')" >> "$R"
done
section "Certificates"
certbot certificates 2>/dev/null | grep -E 'Certificate Name|Expiry' | paste - - | awk '{print $3, $(NF-1), $NF}' >> "$R"
# --- deliver ---
SUBJECT="Weekly report: $H $(date +%F)"
mail -s "$SUBJECT" you@example.com < "$R"
curl -s -H "Title: $SUBJECT" -H "Authorization: Bearer $NTFY_TOKEN" --data-binary @"$R" https://ntfy.example.com/server > /dev/null
rm -f "$R"
sudo chmod +x /usr/local/bin/weekly-report.sh. Put NTFY_TOKEN in an env file the script sources, or drop the curl line and keep email only. Run it once by hand: sudo /usr/local/bin/weekly-report.sh — read the message, fix anything it reveals.
2. Every Monday
A systemd timer (69) so a server that was off at 7 am sends it when it's next up:
sudo nano /etc/systemd/system/weekly-report.timer
[Unit]
Description=Weekly server report
[Timer]
OnCalendar=Mon 07:00
Persistent=true
[Install]
WantedBy=timers.target
and a matching weekly-report.service with ExecStart=/usr/local/bin/weekly-report.sh. sudo systemctl enable --now weekly-report.timer. Or root's crontab: 0 7 * * 1 /usr/local/bin/weekly-report.sh.
3. Make it yours
- Add a line per thing you care about: Pi-hole's blocked percentage (curl -s http://127.0.0.1/api/stats/summary), Immich's job queue, the UPS battery (upsc myups battery.charge), ZFS zpool status -x, SnapRAID's last sync, docker system df for image bloat.
- Warnings in CAPS at the top: move the WARNING/REBOOT/RESTARTED lines to a summary section first so the phone preview shows them.
- Several machines: the script runs on each (via the Ansible baseline, 140); or the main server pulls ssh pi weekly-report.sh output into one message.
- HTML email with mail -a "Content-Type: text/html" if you want tables; plain text reads fine on a phone.
4. Read it
Two minutes on Monday with coffee. Disk up 3% a week → look at Immich. A container restarted → check its log in Dozzle (125). An Accepted key fingerprint you don't recognise → tutorial 71, now. Certificate expiring in 10 days → renewal is failing. Nothing odd → close it. That's the review, done by the server.
Troubleshooting
Empty sections. The command needs root (smartctl, journalctl, restic) — run from root's timer/cron, not yours.
mail sends nothing. msmtp (49) not configured; test with echo hi | mail -s test you@example.com.
Report arrives late or not at all after a weekend off. Persistent=true on the timer handles it; cron doesn't.
Too long. Trim sections, or send a short summary by ntfy and the full report by email.
Wrapping up
One script that runs the commands you'd otherwise forget, a timer for Monday morning, email and ntfy delivery. The slow problems — the ones that never trigger an alert — show up as a line in a message you actually read, weeks before they'd have become an outage.
