← Back to the playbook

Server hardening scripts

The runnable companion to the agent server hardening playbook. The playbook explains the reasoning; these do the work. Public domain, no host or application specifics, every site-specific value is a variable.

Read this, do not pipe it. These scripts run as root. Copy the source into a file on your own machine, read it, and run it yourself. Nobody should curl a root shell script straight off a website, including this one.

The runnable companion to the Agent Server Hardening playbook. The playbook explains the reasoning; these scripts do the work.

Public domain / CC0. No host, provider or application specifics: everything site-specific is a variable. Take them, read them, adapt them.

The scripts

Script Lockout risk What
audit.sh none (read-only) Reports what's wrong. Safe for anyone to run.
harden-1-safe.sh none ~90% of the work: sysctls, perms, patching, etckeeper, npm supply chain, systemd sandboxing, nginx TLS+headers, docker.
harden-2-firewall.sh yes ufw ingress + per-uid egress. Arms an auto-rollback.
harden-3-ssh.sh yes sshd hardening. Arms an auto-rollback.

lib.sh is shared helpers, sourced, never run directly.

Order

./audit.sh                          # see where you stand
sudo DRY_RUN=1 ./harden-1-safe.sh   # read what would change
sudo ./harden-1-safe.sh             # apply, re-runnable
sudo ./harden-2-firewall.sh         # rollback armed
sudo ./harden-3-ssh.sh              # rollback armed
./audit.sh                          # confirm

Not locking yourself out

Scripts 2 and 3 each arm an automatic undo before making the change:

systemd-run --on-active=10min --unit=… <undo command>
   ↓ apply change
   ↓ you verify from a SECOND terminal
   ↓ only then is the timer cancelled

Lose access and the box heals itself in ≤10 minutes. Set ROLLBACK_MIN to change the window.

Additional guards: - harden-3-ssh.sh refuses to run if the admin user has no authorized_keys. That is the classic way people lock themselves out. - sshd -t validates before any reload; a bad config reverts itself. - harden-2 refuses TAILSCALE_ONLY_SSH=1 unless tailscale status is healthy. - Manual escape hatch: /usr/local/sbin/ssh-harden-rollback, and ufw --force disable.

Before you start: confirm the provider's web/VNC console works. That is the only path that doesn't depend on SSH or the firewall.

Knobs

Set them inline (SANDBOX_SERVICES='api worker' sudo ./harden-1-safe.sh) or put them in harden.local.conf next to the scripts, which lib.sh sources if it exists. That file is gitignored, so your values stay out of the repo.

Var Default Effect
DRY_RUN 0 1 = show, change nothing
ROLLBACK_MIN 10 auto-undo window
APP_USER / ADMIN_USER the sudo caller the human account that owns the apps
SANDBOX_SERVICES (empty) your unit names, space separated. Empty = section 9 does nothing
AGENT_USERS (empty) accounts running agents, for the egress rules
TAILSCALE_ONLY_SSH 0 1 = close public SSH (verify Tailscale first)
KEEP_IPV6 0 1 = keep IPv6 up (then ufw must manage it)
STRICT_AGENT_EGRESS 0 1 = enforce agent egress instead of logging
STRICT_WP_EGRESS 0 1 = enforce WordPress egress instead of logging
LOCK_CUSTOMER_SHELLS 1 0 = leave non-admin shells alone

SANDBOX_SERVICES and AGENT_USERS are empty on purpose. A guessed default would either miss your services or confine something that then breaks quietly.

Deliberately not automated

Some things restart services or need judgement, so the scripts write the config and tell you:

The IPv6 trap

IPV6=no in /etc/default/ufw means ufw does not manage ip6tables at all. That's only safe if IPv6 is also down at the kernel. If IPv6 is up and ufw ignores it, every service is exposed on v6 with no firewall. harden-1 and harden-2 keep these two settings consistent and audit.sh flags the dangerous combination.

audit.sh as a public tool

audit.sh is written to be published: read-only, no network, no writes, and redacted by default (no IPs, hostnames or usernames unless --full). So the output is safe to screenshot.

Its angle is the one general VPS guides miss: not "is this server hardened" but "is the blast radius of an agent that can execute code and hold credentials actually contained?" Checks marked ASIxx map to the OWASP Top 10 for Agentic Applications (2026).

./audit.sh --json | jq .     # machine-readable

Exit code is 1 if anything failed, 0 otherwise.