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.
- audit.shRead-only. Reports what is wrong and changes nothing, so it is safe to run first and safe to run on a production box.
- harden-1-safe.shRoughly 90% of the work, and none of it can lock you out. Sysctls, mount flags, patching, permissions, npm supply chain, systemd sandboxing, nginx.
- harden-2-firewall.shufw ingress plus per-uid egress control. Can lock you out, so it arms an automatic rollback before it changes anything.
- harden-3-ssh.shsshd hardening. Can lock you out, so it refuses to run without a working key and arms an automatic rollback.
- lib.shShared helpers: dry-run handling, idempotent writes, and the rollback timer the two risky scripts depend on. Sourced, never run directly.
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:
- restarting each sandboxed service (watch each one; add paths to
ReadWritePaths=if it breaks) systemctl restart docker(restarts every container)- adding
include snippets/security-headers.conf;to each 443 block pro attach <TOKEN>- a reboot to pick up
/tmpmount flags
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.