← shadman.io
// project write-up

From Three Scripts to a Toolkit

Rebuilding three beginner Bash scripts into a portable, production-style toolkit for Linux servers, and the reasoning behind every decision.

Jun 2026  ·  9 min read  ·  POSIX shell
POSIX shell ShellCheck-clean Linux rsync · ufw · cron
health_check.sh — output
$ sudo ./bin/health_check.sh

=== System Health Report ===
Host: web-01    2026-06-15 14:00:01

Disk usage
  /                    [ OK  ] 42% used, 53G free
  /var                 [WARN] 88% used, 4.1G free
Memory
  RAM                  [ OK  ] 61% used (4.8GB of 7.8GB)
CPU load
  Load (1 min)         [ OK  ] 0.74 on 4 core(s)
Critical services
  sshd                 [ OK  ] active
  ufw                  [ OK  ] active

Status: 1 issue(s) detected.
[alert] WEBHOOK_URL not set, notification skipped.

I recently took three small automation scripts and rebuilt them to the standard you would actually want running on a real server. This is that project start to finish, including the reasoning behind each decision, because in interviews the reasoning is what gets tested, not the syntax. You do not need to write shell scripts already to follow along.

00A quick primer#

A shell script is a text file full of commands, the same ones you would type into a Linux terminal, saved so the machine can run them in order for you. Instead of typing twenty commands every night to back up your files, you write them once and let the computer repeat them. That is automation at its simplest.

The "shell" is the program that reads those commands. bash is the most common one. sh is an older, simpler, more universal version, and that difference matters later. The project became three tools that share one settings file and install with a single command: a backup tool, an SSH guard that blocks break-in attempts, and a health checker that reports on disk, memory, CPU, and key services.

01The backup tool, and why I moved past the tarball#

The simplest way to back up a folder is to zip it into one compressed file, a tarball. It works, but it is wasteful: every night you copy every file again, even the ones that have not changed in months. A tool called rsync fixes most of that by copying only what changed.

I took it further with hardlinking. Each backup is a folder of files, and most files do not change day to day. Instead of storing a fresh copy of an unchanged file in today's backup, the system creates a hardlink, a second name pointing at the exact same data already on disk. The file appears in both yesterday's and today's backup but only takes up space once. This is the same idea behind Apple's Time Machine. The payoff is real: I can keep ten daily snapshots, each looking like a complete full backup, while using barely more space than a single copy plus whatever actually changed.

I paired that with a retention policy, which simply means automatically deleting backups older than seven days so the disk does not fill up forever. Because the hardlinks share data, deleting an old snapshot is safe: it removes that day's set of pointers, and any file still used by a newer backup stays exactly where it is. The script also logs every action with timestamps, so if a backup fails at 2 a.m. I can read precisely what happened in the morning.

02The SSH guard, real security automation#

SSH is how you log into a Linux server remotely, and it is constantly under attack. Bots roam the internet trying millions of username and password combinations against any server they find. That is a brute-force attack. Every failed login gets recorded in a log file. My script reads that log, counts how many times each IP address failed, and if any address crosses a threshold, it tells the firewall to block it. The attacker is now talking to a wall.

Three details took this from a toy to something I would actually run. A whitelist of trusted addresses the script will never block: the first question anyone sharp asks about an auto-banning tool is "what stops it from locking you out?" and keeping my own IP whitelisted is that answer. It is idempotent, a word worth knowing, meaning running it twice causes no harm, because it checks whether an address is already blocked before acting. And it works with two firewalls, ufw and iptables, by detecting which is available. That kind of flexibility is what separates a tool that works on my machine from one that works on machines in general.

03The health checker, a server's vital signs#

This one is a quick physical exam for a server. It checks four things and prints a clean, color-coded report, the kind shown above. Disk space: if any drive passes 85 percent full, it warns, since a full disk is one of the most common ways a server quietly dies. Memory: it reports real RAM usage from the kernel's own "available memory" figure rather than the naive "free" number, because Linux deliberately uses spare memory as cache. CPU load: the load average only means something relative to your core count, so it compares against the number of cores instead of a fixed value. Critical services: it confirms important programs like the SSH service and the firewall are actually running.

If something is wrong, it can fire an alert to Slack or Discord through a webhook, a special URL that posts a message when you send data to it. The webhook URL is read from the environment rather than written into the file, so a secret never ends up published to a public repository.

04Never hardcode#

At first, every setting lived inside the scripts. That is fine for one machine but fragile, since changing a value means hunting through code and one typo can break everything. So I moved every setting into one shared config file. All three tools read from it, so to add a folder to back up or change the disk warning level, I edit one line in one obvious place and never touch the logic. Keeping settings separate from logic is everywhere in professional software. It is the same reason your phone has a Settings app instead of making you rewrite the operating system to change the wallpaper.

05One command to set it up#

A good tool installs cleanly, so I wrote an installer that does three jobs at once: it makes the scripts executable, creates a dedicated folder for logs, and schedules everything with cron, the built-in Linux timer for recurring tasks. The backup runs daily, the health check hourly. Rather than editing a user's personal schedule, I drop a small file into /etc/cron.d, a cleaner approach because re-running the installer refreshes that one file instead of piling up duplicate entries.

06Going portable with POSIX, and what it cost#

This is where the project taught me the most. I had written everything in bash, but bash is not installed everywhere. Smaller systems, many containers, and other flavors of Unix use that simpler shell, sh. So I rewrote it all to follow a standard called POSIX, the common ground nearly every shell understands. Think of it as writing in plain, universal English instead of regional slang.

That was not free. Bash has conveniences POSIX lacks. Its arrays, for holding a list in one variable, became plain space-separated strings I loop over. Its slick way to pipe a command's output into a loop became writing that output to a temporary file and reading the loop from the file. The result is identical; the route there is different. Being able to explain that trade, what I gave up and what I gained, turned out to be one of the more valuable things I took from the project. It is a genuine engineering decision, not just typing.

07Proving it works#

Claiming code is good is easy. Proving it is the professional habit. I used ShellCheck, a linter, which reads your code and flags mistakes and risky patterns before you ever run it. Picture spellcheck and a careful proofreader, for code. I ran it in its strictest POSIX mode and fixed everything it flagged until it reported zero issues, then ran the scripts under that simpler shell to confirm they really worked there and not only in bash. A clean lint is something you can point to, and it signals that you care about quality rather than just output.

08Honest about the edges#

The last thing I added was a "Limitations" section to the project's README. Listing my own project's weaknesses felt backwards at first, but it is the difference between reading like a beginner and reading like an operator. A reviewer who spots a gap you did not mention assumes you missed it. A reviewer who sees you named it first assumes you understand the whole picture and scoped the project on purpose.

The SSH guard only catches the older style of IP address, not the newer IPv6 kind. Its bans stay until I lift them by hand. It expects an Ubuntu-style log location. The backup is only truly safe if it lands on a separate machine, not the same disk. The health check is a single snapshot and does not track trends.

I also pointed to the mature tools that solve these at scale: fail2ban for intrusion blocking, Prometheus and Grafana for monitoring. Building my own versions was never about beating those tools. It was about understanding how they work underneath.

09What I took from it#

Zoom out, and this was never really about three scripts. It was about the gap between code that runs and code you would trust on a server you care about. That gap is made of small, unglamorous habits: separate settings from logic, make tools safe to run twice, protect yourself from your own automation, log so future-you can debug, verify with a linter instead of hoping, choose portability deliberately, and stay honest about the edges.

None of those are hard on their own. Together, they are most of what stands between writing code and being handed the keys. If you are heading toward this kind of work, build something small and then spend your time making it good. The "making it good" part is where the real learning lives.