• Post author:Bhaskar Soni
  • Post published:
  • Post category:Linux
  • Post comments:0 Comments
  • Reading time:14 mins read

Every sysadmin ends up with a folder of half-finished shell scripts. Some are brilliant; most are untested and run as root at 3 a.m. This post gives you ten Linux automation scripts that we actually ran, linted with ShellCheck, and designed with least privilege in mind. Each one covers a routine task: patch visibility, user audits, SSH abuse, port and file drift, backups, health logging, cleanup, service recovery, and onboarding. Copy them, read them, then adapt them to your hosts.

Before you deploy any Linux automation scripts

First, decide where scripts live. We use /usr/local/sbin, owned root:root with mode 750, so only root can read or run them. A world-writable script that cron runs as root is a privilege-escalation gift. Keep the directory in version control, and never store secrets inside a script. Use a 600 environment file or a secrets manager instead.

Then, lint before you schedule. bash -n script.sh catches syntax errors; shellcheck script.sh catches quoting and set -e traps. Every script below starts with set -euo pipefail, which stops on errors, unset variables and broken pipes. Because of pipefail, a grep that matches nothing will abort a script, so you will see || true where an empty result is normal.

Finally, make your Linux automation scripts log everything. Append to a file under /var/log, and use logger so entries also reach journald or your SIEM. CERT-In expects Indian organisations to retain logs for 180 days, so give these logs a logrotate rule rather than deleting them weekly. We tested all ten with bash -n and ShellCheck 0.11 on Ubuntu 24.04. Scripts 2, 3, 5, 6, 7 and 8 also ran end to end in that sandbox; the rest were tested only for syntax and argument validation because our sandbox has no systemd PID 1 or ss.

Scheduling: cron or systemd timers

Cron is universal. Add a file in /etc/cron.d/ with the user named explicitly:

# /etc/cron.d/update-report  (06:30 IST if the host clock is Asia/Kolkata)
30 6 * * * root /usr/local/sbin/update-report.sh

Many cloud VMs run in UTC, so either set timedatectl set-timezone Asia/Kolkata or convert the hour. Systemd timers add logging, randomised delays and catch-up after downtime. A minimal pair looks like this, and OnCalendar accepts a timezone suffix on current systemd releases:

# /etc/systemd/system/health-line.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/health-line.sh

# /etc/systemd/system/health-line.timer
[Timer]
OnCalendar=*:0/5
Persistent=true
[Install]
WantedBy=timers.target

Enable with systemctl enable --now health-line.timer. Now, the scripts.

1. Pending security updates report

This script reports what needs patching without installing anything, so a human still decides when to reboot. It handles both apt and dnf; note that dnf check-update exits 100 when updates exist.

#!/usr/bin/env bash
# update-report.sh - list pending updates; never installs, never reboots
set -euo pipefail
LOG=/var/log/update-report.log
{
  echo "=== $(date -Is) $(hostname) ==="
  if command -v apt-get >/dev/null; then
    apt-get update -qq
    apt list --upgradable 2>/dev/null | grep -v '^Listing'
  elif command -v dnf >/dev/null; then
    dnf -q check-update || true       # exit 100 means updates exist
    dnf -q updateinfo list security || true
  else
    echo "unsupported package manager"
  fi
  [ -f /var/run/reboot-required ] && echo "REBOOT REQUIRED"
} >> "$LOG" 2>&1

Schedule: daily at 06:30 IST via /etc/cron.d. Security note: run apt-get update here only, and keep unattended-upgrades or dnf-automatic for actual installs, so reboots never happen silently. See our Linux hardening checklist for patch cadence.

2. Privileged user audit

Finds UID 0 accounts, members of sudo (Debian family) or wheel (RHEL family), raw sudoers.d entries, interactive users whose password never expires, and empty password fields.

#!/usr/bin/env bash
# user-audit.sh - privileged and never-expiring accounts
set -euo pipefail
echo "## UID 0 accounts"
awk -F: '$3 == 0 {print $1}' /etc/passwd
echo "## Members of sudo / wheel"
for g in sudo wheel; do
  getent group "$g" | awk -F: -v g="$g" '$4 != "" {print g": "$4}' || true
done
echo "## Entries in /etc/sudoers.d"
grep -rhE '^[^#%]\S+\s+ALL' /etc/sudoers.d 2>/dev/null | awk '{print $1}' | sort -u || true
echo "## Login-capable users with no password expiry"
while IFS=: read -r user _ uid _ _ _ shell; do
  [ "$uid" -ge 1000 ] || continue
  case "$shell" in */nologin|*/false) continue ;; esac
  chage -l "$user" 2>/dev/null | grep -q 'Password expires.*never' && echo "$user"
done < /etc/passwd
echo "## Empty password fields"
awk -F: '$2 == "" {print $1}' /etc/shadow 2>/dev/null || echo "(run as root)"

Schedule: weekly, with output mailed or pushed to a ticket. Security note: the script only reads /etc/shadow as root; run it with sudo, never by making shadow world-readable.

3. Failed SSH login summary

Reads journald first (ssh.service on Debian/Ubuntu, sshd.service on RHEL), then falls back to auth.log or secure. We tested the parser against a sample log with four failures.

#!/usr/bin/env bash
# ssh-failures.sh - failed SSH logins in the last 24h, top IPs and usernames
set -euo pipefail
TOP=${1:-10}
if journalctl -q -u ssh -u sshd --since "24 hours ago" >/dev/null 2>&1; then
  SRC=$(journalctl -q -u ssh -u sshd --since "24 hours ago" --no-pager)
elif [ -r /var/log/auth.log ]; then SRC=$(cat /var/log/auth.log)   # Debian/Ubuntu
elif [ -r /var/log/secure ];   then SRC=$(cat /var/log/secure)     # RHEL family
else echo "no SSH log source found" >&2; exit 1; fi
FAILED=$(printf '%s\n' "$SRC" | grep -E 'Failed password|Invalid user' || true)
echo "Failed attempts: $(printf '%s\n' "$FAILED" | grep -c . || true)"
echo "Top $TOP source IPs:"
printf '%s\n' "$FAILED" | grep -oE 'from ([0-9]{1,3}\.){3}[0-9]{1,3}' \
  | awk '{print $2}' | sort | uniq -c | sort -rn | head -n "$TOP" || true
echo "Top $TOP usernames tried:"
printf '%s\n' "$FAILED" | grep -oE '(for|user) [A-Za-z0-9._-]+ from' \
  | awk '{print $2}' | sort | uniq -c | sort -rn | head -n "$TOP" || true

Schedule: daily. Security note: this is a report, not a blocker. For automatic blocking use fail2ban or key-only authentication, and check our tools directory for IP reputation lookups.

4. Listening ports drift

A new listening port usually means a new service, a misconfiguration or malware. The first run stores a baseline; later runs diff against it and exit 2 on change.

#!/usr/bin/env bash
# port-diff.sh - snapshot listening sockets and diff against a baseline
set -euo pipefail
STATE=${STATE_DIR:-/var/lib/port-audit}
command -v ss >/dev/null || { echo "ss (iproute2) not found" >&2; exit 1; }
mkdir -p "$STATE"
BASE="$STATE/baseline.txt"; NOW="$STATE/current.txt"
# proto, local address:port, owning process (process names need root)
ss -Hlntup | awk '{print $1, $5, $7}' | sort -u > "$NOW"
if [ ! -s "$BASE" ]; then
  cp "$NOW" "$BASE"; echo "baseline created: $(wc -l < "$BASE") sockets"; exit 0
fi
if diff -u "$BASE" "$NOW" > "$STATE/diff.txt"; then
  echo "no change in listening ports"
else
  echo "LISTENING PORTS CHANGED on $(hostname) at $(date -Is)"; cat "$STATE/diff.txt"
  logger -t port-diff "listening ports changed; see $STATE/diff.txt"; exit 2
fi

Schedule: hourly. Security note: review and re-baseline deliberately with cp current.txt baseline.txt; never let the script auto-accept changes.

5. File integrity snapshot of /etc

A lightweight alternative to AIDE for small fleets. It hashes every file, reports modified, added and removed paths, then accepts the new state.

#!/usr/bin/env bash
# etc-integrity.sh - hash a tree and report changes since the last run
set -euo pipefail
TARGET=${1:-/etc}
STATE=${STATE_DIR:-/var/lib/integrity}; mkdir -p "$STATE"
BASE="$STATE/$(printf '%s' "$TARGET" | tr '/' '_').sha256"; NOW="$BASE.new"
find "$TARGET" -xdev -type f -print0 2>/dev/null | sort -z \
  | xargs -0 -r sha256sum > "$NOW" 2>/dev/null || true
if [ ! -s "$BASE" ]; then
  mv "$NOW" "$BASE"; echo "baseline stored: $(wc -l < "$BASE") files"; exit 0
fi
ADDREM=$(diff <(cut -d' ' -f3- "$BASE") <(cut -d' ' -f3- "$NOW") || true)
MODIFIED=$(sha256sum --check --quiet "$BASE" 2>/dev/null | grep -v 'open or read' || true)
if [ -z "$ADDREM" ] && [ -z "$MODIFIED" ]; then
  echo "no changes under $TARGET"
else
  echo "CHANGES under $TARGET at $(date -Is)"
  [ -n "$MODIFIED" ] && printf 'Modified:\n%s\n' "$MODIFIED"
  [ -n "$ADDREM" ]   && printf 'Added/removed:\n%s\n' "$ADDREM"
  logger -t etc-integrity "changes detected under $TARGET"
fi
mv "$NOW" "$BASE"   # accept new state; review the report before trusting it

Schedule: daily, right after your patch window. Security note: an attacker with root can edit the baseline, so copy /var/lib/integrity off-host or compare against a read-only copy.

6. Backup of /etc and a web root with retention

Creates a compressed, permission-preserving archive with a checksum, then deletes archives older than KEEP_DAYS. GNU tar exits 1 when files change mid-read, which we treat as a warning.

#!/usr/bin/env bash
# backup.sh - tar.gz selected paths, checksum them, keep the last N days
set -euo pipefail
DEST=${DEST:-/var/backups/host}; KEEP_DAYS=${KEEP_DAYS:-14}
read -ra SOURCES <<< "${SOURCES:-/etc /var/www}"
HOST=$(hostname -s); OUT="$DEST/$HOST-$(date +%Y%m%d-%H%M%S).tar.gz"
mkdir -p "$DEST"; chmod 700 "$DEST"
tar --warning=no-file-changed -czpf "$OUT" "${SOURCES[@]}" 2>/dev/null || {
  rc=$?   # tar exit 1 = "some files changed while reading"; anything else is fatal
  [ "$rc" -eq 1 ] || { echo "backup FAILED rc=$rc" >&2; rm -f "$OUT"; exit "$rc"; }
}
chmod 600 "$OUT"; sha256sum "$OUT" > "$OUT.sha256"
echo "$(date -Is) wrote $OUT ($(du -h "$OUT" | cut -f1))"
find "$DEST" -maxdepth 1 -name "$HOST-*.tar.gz*" -mtime +"$KEEP_DAYS" -print -delete

Schedule: nightly at 02:00 IST, followed by an rsync or object-storage upload. Security note: the archive contains /etc/shadow and private keys. Mode 600 is the minimum; encrypt with age or gpg before it leaves the host.

7. Disk, memory and load one-liner

Appends one parseable line per run and raises a syslog warning only when thresholds are crossed.

#!/usr/bin/env bash
# health-line.sh - one line of disk, memory and load per run
set -euo pipefail
LOG=${LOG:-/var/log/health-line.log}
ROOT_USE=$(df -P / | awk 'NR==2 {gsub("%","",$5); print $5}')
MEM_USE=$(free -m | awk '/^Mem:/ {printf "%d", ($3/$2)*100}')
LOAD=$(cut -d' ' -f1-3 /proc/loadavg); CORES=$(nproc)
LINE="$(date -Is) host=$(hostname -s) root_disk=${ROOT_USE}% mem=${MEM_USE}% load=${LOAD// /,} cores=$CORES"
echo "$LINE" >> "$LOG"
WARN=""
[ "$ROOT_USE" -ge 85 ] && WARN+=" disk"
[ "$MEM_USE"  -ge 90 ] && WARN+=" memory"
awk -v l="${LOAD%% *}" -v c="$CORES" 'BEGIN {exit !(l > c*2)}' && WARN+=" load"
[ -n "$WARN" ] && logger -p user.warning -t health-line "threshold exceeded:$WARN | $LINE"
exit 0

Schedule: every five minutes with the systemd timer shown earlier. Security note: thresholds live in the script, so a change is a code review, not a quiet edit on a production box.

8. Stale temp and cache cleanup

Dry run by default. It only deletes regular files, never follows into other filesystems, and skips systemd private temp directories.

#!/usr/bin/env bash
# cleanup.sh - remove stale temp and cache files; dry run by default
set -euo pipefail
DAYS=${DAYS:-7}; DRY_RUN=${DRY_RUN:-1}
read -ra DIRS <<< "${DIRS:-/tmp /var/tmp /var/cache/app}"
ACTION=(-print); [ "$DRY_RUN" = "0" ] && ACTION=(-print -delete)
for d in "${DIRS[@]}"; do
  [ -d "$d" ] || continue
  echo "## $d (files older than $DAYS days)"
  # -xdev stays on one filesystem; -type f never removes directories or links
  find "$d" -xdev -mindepth 1 -type f -mtime +"$DAYS" \
    ! -path '*/systemd-private-*' ! -name '.X*-lock' "${ACTION[@]}"
done
if [ "$DRY_RUN" = "0" ]; then
  command -v apt-get >/dev/null && apt-get clean
  command -v dnf >/dev/null && dnf -q clean packages
  journalctl --vacuum-time=180d -q   # keep 180 days for CERT-In
fi
echo "done (dry_run=$DRY_RUN)"

Schedule: weekly with DRY_RUN=0 once you trust the file list. Security note: always quote and validate DIRS; a stray space turns /var/cache/app into /var/cache and /app.

9. Service watchdog

Restarts named units that are not active and logs the outcome. However, systemd already does this better: add Restart=on-failure and RestartSec=5 to the unit’s [Service] section. Use this script only for units you cannot edit, or as a second opinion.

#!/usr/bin/env bash
# watchdog.sh - restart listed systemd units that are not active, and log it
set -euo pipefail
[ "$#" -gt 0 ] || { echo "usage: $0 unit.service [...]" >&2; exit 64; }
LOG=/var/log/watchdog.log; rc=0
for u in "$@"; do
  systemctl is-active --quiet "$u" && continue
  echo "$(date -Is) $u is $(systemctl is-active "$u" || true), restarting" | tee -a "$LOG"
  if systemctl restart "$u" && sleep 3 && systemctl is-active --quiet "$u"; then
    echo "$(date -Is) $u recovered" | tee -a "$LOG"
    logger -t watchdog "restarted $u"
  else
    echo "$(date -Is) $u did NOT recover" | tee -a "$LOG"
    logger -p user.err -t watchdog "$u failed to recover"; rc=1
  fi
done
exit "$rc"

Schedule: every five minutes, for example watchdog.sh nginx.service php8.3-fpm.service. Security note: a restart loop can hide a compromise or a bad config; alert after two consecutive failures instead of looping forever.

10. New-user onboarding

Validates the username, the public key and every group before changing anything, then creates a key-only account.

#!/usr/bin/env bash
# onboard-user.sh - create a user, install an SSH public key, add groups
set -euo pipefail
[ "$#" -ge 2 ] || { echo "usage: $0 <username> <pubkey-file> [group,group]" >&2; exit 64; }
USER_NAME=$1; KEYFILE=$2; GROUPS_CSV=${3:-}
[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; }
# Validate everything before touching the system
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || { echo "bad username" >&2; exit 65; }
[ -r "$KEYFILE" ] || { echo "cannot read $KEYFILE" >&2; exit 66; }
ssh-keygen -l -f "$KEYFILE" >/dev/null || { echo "not a valid public key" >&2; exit 65; }
id "$USER_NAME" >/dev/null 2>&1 && { echo "user exists" >&2; exit 1; }
IFS=, read -ra GROUPS_ARR <<< "$GROUPS_CSV"
for g in "${GROUPS_ARR[@]}"; do
  getent group "$g" >/dev/null || { echo "no such group: $g" >&2; exit 65; }
done
useradd -m -s /bin/bash "$USER_NAME"       # works on Debian and RHEL families
passwd -l "$USER_NAME"                     # key-only login, no password
for g in "${GROUPS_ARR[@]}"; do usermod -aG "$g" "$USER_NAME"; done
HOME_DIR=$(getent passwd "$USER_NAME" | cut -d: -f6)
install -d -m 700 -o "$USER_NAME" -g "$USER_NAME" "$HOME_DIR/.ssh"
install -m 600 -o "$USER_NAME" -g "$USER_NAME" "$KEYFILE" "$HOME_DIR/.ssh/authorized_keys"
logger -t onboard-user "created $USER_NAME groups=${GROUPS_CSV:-none} by $(logname 2>/dev/null || echo root)"
echo "created $USER_NAME; groups: $(id -nG "$USER_NAME")"

Run manually: onboard-user.sh priya ./priya.pub sudo,developers. Security note: never pass private keys, and record who ran it; the logger line uses logname for exactly that reason. Review new sudo members with script 2.

Testing and rollout

In short: lint, dry-run, then schedule. Run bash -n and shellcheck in CI, test on a staging VM with DRY_RUN=1 or temporary STATE_DIR paths, and only then install to /usr/local/sbin. If you want a second pair of eyes on your automation and hardening, our Linux server hardening audit covers exactly this.

Frequently asked questions

Should Linux automation scripts run as root?

Only when they must. Scripts 1, 2, 5, 6, 8, 9 and 10 need root. Script 3 works for members of adm or systemd-journal, and script 7 needs no privilege at all. Create a dedicated user for the rest where possible.

Cron or systemd timers: which is better?

Timers give you journalctl -u, Persistent=true catch-up and RandomizedDelaySec. Cron is simpler and identical on every distribution. Use timers on systemd hosts and cron everywhere else.

Why not just use Ansible?

Ansible is better for configuration across many hosts. These Linux automation scripts handle per-host, time-driven tasks, and Ansible can deploy them. The two complement each other.

How do I keep these scripts safe from tampering?

Store them in Git, deploy with install -m 750 -o root -g root, and include /usr/local/sbin in the integrity snapshot from script 5. Any unexpected change then shows up in the daily report.

Do these work on RHEL, Rocky and AlmaLinux?

Yes. The package, SSH unit and sudo group names differ, and each script checks for both. Paths such as /var/log/secure and the wheel group are handled explicitly.

Related guides

Sources

Running Linux servers in production? Our Linux server hardening & security audit checks configuration, patching, SSH and logging. You can also use our free Linux server hardening checklist, or contact us.

Post author avatar

Bhaskar Soni

Bhaskar Soni is a Security Engineer and the founder of Youngster Company, based in Ahmedabad, Gujarat. He works hands-on with Linux server hardening, vulnerability assessment and penetration testing (VAPT), ISO 27001 compliance and OSINT investigations. He writes practical, tested guides for IT professionals and Indian businesses, and every article on this site is written or reviewed by him.

Leave a Reply