One opinionated bash script. SSH lockdown, UFW, Tailscale, fail2ban, sysctl, unattended-upgrades, wired up before your first deploy. Sensible defaults, auto-detection, four PROFILE presets. No flags to memorize.
# one line. sensible defaults. auto-detects the rest.
curl -fsSL https://raw.githubusercontent.com/aiherrera/vps-hardener/main/hardener.sh | sudo bash
[+] detected SSH session → ALLOW_SSH_FROM=203.0.113.7/32
[+] no listener on :80/:443 → skipping UFW web allow
[+] creating deploy user with sudo + ssh keys
[+] writing /etc/ssh/sshd_config.d/99-vps-hardening.conf
[+] UFW: default deny incoming, allow 22/tcp from CIDR
[+] fail2ban enabled (public SSH still reachable)
[✓] hardening complete. fail-safe: requires SSH keys on the server: /root/.ssh/authorized_keys, $SUDO_USER keys, or SSH_PUBLIC_KEY. The script exits otherwise to prevent lockout. No bypass flag.
Pick whichever path matches how you set up the box. Any one of these three is enough; the script checks all of them.
Cloud-init or your provider already populated ~/.ssh/authorized_keys for root or your sudo user. Nothing to do.
Pass your key inline (works with the pipe install:
sudo SSH_PUBLIC_KEY="$(cat ~/.ssh/id_ed25519.pub)" bash hardener.sh
mkdir -p /root/.ssh && chmod 700 /root/.ssh echo "ssh-ed25519 AAAA…" \ >> /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys
WARNING: No SSH keys found. This script disables root and password login.
WARNING: Set SSH_PUBLIC_KEY or populate /root/.ssh/authorized_keys before continuing.
Example:
sudo SSH_PUBLIC_KEY="$(cat ~/.ssh/id_ed25519.pub)" bash hardener.sh
Stopping to prevent SSH lockout.Disables root + password auth, restricts AllowUsers, drops a managed sshd_config.d override.
Default deny incoming. Auto-opens 80/443 only if something is listening. CIDR allow-lists when SSH stays public.
Drop in TAILSCALE_AUTHKEY — Tailscale installs, joins, and public SSH closes automatically after the tailnet is up.
Creates a non-root sudo user, copies your authorized_keys, and verifies before locking root.
Sane kernel hardening, persistent journald, chrony for time sync, unattended-upgrades on by default.
Auto-enabled whenever public SSH remains reachable. Sensible jails out of the box.
Reads sshd -T, system timezone, listening ports, your live SSH source IP — fills in the variables you didn't pass.
Safe to re-run. Drop-in configs are owned by the script; reruns reconcile state without breaking existing sessions.
Pick a preset, choose where your SSH key lives, drop in extras, copy. Precedence: explicit env var > PROFILE > auto-detect.
The script refuses to run unless it can find a key. Pick where yours lives.
curl -fsSL https://raw.githubusercontent.com/aiherrera/vps-hardener/main/hardener.sh | \ sudo bash
The five questions sysadmins ask after running a hardening script for the first time.
No SSH keys were found. Populate /root/.ssh/authorized_keys, log in as a sudo user whose authorized_keys is set, or pass SSH_PUBLIC_KEY="$(cat ~/.ssh/id_ed25519.pub)" on the same sudo line.
Use your provider’s web console / rescue mode. The drop-in lives at /etc/ssh/sshd_config.d/99-vps-hardening.conf — remove or edit it, then `systemctl restart ssh`. UFW: `ufw status numbered` and `ufw delete <n>`. Full recovery in docs/troubleshooting.md.
Without a valid TAILSCALE_AUTHKEY the script logs a WARNING and keeps public SSH so you don’t lose access. Re-run with a working key, or `tailscale up` manually and re-run with KEEP_PUBLIC_SSH=false.
fail2ban is only enabled while public SSH is reachable. On tailnet-only setups it isn’t needed. Check `systemctl status fail2ban` and `fail2ban-client status sshd`.
Precedence is: explicit env var > PROFILE > auto-detect. Anything you set on the sudo line always overrides the preset and the script’s heuristics.
full recovery walkthrough → docs/troubleshooting
One-click copies of the commands from docs/troubleshooting. Run them from a rescue shell or provider console.
sshd -t passes.For every variable you don’t pass, the script inspects the box and picks a sane value. Anything you set explicitly always wins.
full rules → docs/configuration
Every knob is an env var. The builder above covers the common ones; this table is the full reference.
| variable | default | description |
|---|---|---|
| PROFILE | — | minimal · web · tailscale · lockdown |
| DEPLOY_USER | deploy | Sudo user (auto = use $SUDO_USER) |
| TAILSCALE_AUTHKEY | — | Tailscale auth key (or TAILSCALE_AUTHKEY_FILE) |
| USE_TAILSCALE | false | Install Tailscale (auto true if auth key set) |
| KEEP_PUBLIC_SSH | true | Public WAN SSH (auto false after Tailscale join) |
| SSH_PORT | 22 | SSH port (auto from sshd -T if non-default) |
| SSH_PUBLIC_KEY | — | Public key line for deploy user (required if no keys on host) |
| SSH_AUTHORIZED_KEYS | — | Path to existing authorized_keys file |
| ALLOW_SSH_FROM | — | CIDR allow-list (auto from SSH session on lockdown) |
| ALLOW_HTTP / ALLOW_HTTPS | true | UFW web ports (auto false if not listening) |
| TIMEZONE | UTC | System timezone (auto from OS if set) |
| INSTALL_MICRO | false | Install the micro editor |
full reference → docs/configuration
Ship a hardened box every time. MIT licensed. Audited by ShellCheck on every push.