fresh Debian / Ubuntu, Hetzner-friendly defaults

Harden a fresh VPS in 60 seconds.

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.

root@vps:~#
# 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. 
requires
  • Debian 11+ / Ubuntu 22.04+
  • root or sudo access
  • ssh key in authorized_keys
build your command

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.

// before you run

Make sure your SSH key is reachable.

Pick whichever path matches how you set up the box. Any one of these three is enough; the script checks all of them.

A · already on the server

Cloud-init or your provider already populated ~/.ssh/authorized_keys for root or your sudo user. Nothing to do.

B · paste during install

Pass your key inline (works with the pipe install:

sudo SSH_PUBLIC_KEY="$(cat ~/.ssh/id_ed25519.pub)" bash hardener.sh
C · upload first, then run
mkdir -p /root/.ssh && chmod 700 /root/.ssh echo "ssh-ed25519 AAAA…" \ >> /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys
verbatim from hardener.sh
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.
// modules

What gets hardened.

01

SSH lockdown

Disables root + password auth, restricts AllowUsers, drops a managed sshd_config.d override.

02

UFW firewall

Default deny incoming. Auto-opens 80/443 only if something is listening. CIDR allow-lists when SSH stays public.

03

Tailscale ready

Drop in TAILSCALE_AUTHKEY — Tailscale installs, joins, and public SSH closes automatically after the tailnet is up.

04

Deploy user

Creates a non-root sudo user, copies your authorized_keys, and verifies before locking root.

05

Sysctl & journald

Sane kernel hardening, persistent journald, chrony for time sync, unattended-upgrades on by default.

06

fail2ban

Auto-enabled whenever public SSH remains reachable. Sensible jails out of the box.

07

Auto-detection

Reads sshd -T, system timezone, listening ports, your live SSH source IP — fills in the variables you didn't pass.

08

Idempotent

Safe to re-run. Drop-in configs are owned by the script; reruns reconcile state without breaking existing sessions.

// builder

Build your install command.

Pick a preset, choose where your SSH key lives, drop in extras, copy. Precedence: explicit env var > PROFILE > auto-detect.

step 01 · preset
sets:(auto-detect only)
  • Requires /root/.ssh/authorized_keys or SSH_PUBLIC_KEY
Read example: Basic hardening
sets:PROFILE=minimal
  • Equivalent to ALLOW_HTTP=false ALLOW_HTTPS=false
Read example: Minimal
sets:PROFILE=web
Read example: Web-facing production
sets:PROFILE=tailscale
  • USE_TAILSCALE inferred from TAILSCALE_AUTHKEY
  • KEEP_PUBLIC_SSH auto false after tailnet join
Read example: Tailscale
sets:PROFILE=lockdown
  • KEEP_PUBLIC_SSH auto false
  • ALLOW_SSH_FROM auto-detected from SSH session
Read example: Office IP only
sets:PROFILE=web
  • Combine with TAILSCALE_AUTHKEY
Read example: Web-facing production
step 02 · SSH key source

The script refuses to run unless it can find a key. Pick where yours lives.

step 03 · options
your command
curl -fsSL https://raw.githubusercontent.com/aiherrera/vps-hardener/main/hardener.sh | \
  sudo bash
// safety

Safety & recovery.

The five questions sysadmins ask after running a hardening script for the first time.

01Script refused to run — “Stopping to prevent SSH lockout.”

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.

02I’m locked out after a run.

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.

03Tailscale didn’t join — public SSH stayed open.

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.

04fail2ban isn’t banning anything.

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`.

05Which value wins when I set the same thing two ways?

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

recovery kit

One-click copies of the commands from docs/troubleshooting. Run them from a rescue shell or provider console.

  1. Open your provider's web console / rescue mode.
  2. Re-check UFW, then sshd, then test with a second SSH session.
  3. Only restart sshd after sshd -t passes.
// auto-detect

What the script figures out on its own.

For every variable you don’t pass, the script inspects the box and picks a sane value. Anything you set explicitly always wins.

SSH_PORT
sourcesshd -Tfallback22 if standard
TIMEZONE
sourcetimedatectl / /etc/timezonefallbackUTC
ALLOW_HTTP / ALLOW_HTTPS
sourcess -tlnp on :80 :443fallbackfalse if nothing listening
ALLOW_SSH_FROM (lockdown)
sourceSSH_CLIENT / SSH_CONNECTIONfallbackcurrent SSH source /32
USE_TAILSCALE
sourceTAILSCALE_AUTHKEY presencefallbacktrue if key set
KEEP_PUBLIC_SSH
sourcetailnet join successfallbackfalse after tailnet up
DEPLOY_USER
source$SUDO_USER (when set)fallbackdeploy
SSH keys
source/root + $SUDO_USER + SSH_PUBLIC_KEYfallbackexits if none found

full rules → docs/configuration

// env

Environment variable reference.

Every knob is an env var. The builder above covers the common ones; this table is the full reference.

variabledefault
PROFILE
DEPLOY_USERdeploy
TAILSCALE_AUTHKEY
USE_TAILSCALEfalse
KEEP_PUBLIC_SSHtrue
SSH_PORT22
SSH_PUBLIC_KEY
SSH_AUTHORIZED_KEYS
ALLOW_SSH_FROM
ALLOW_HTTP / ALLOW_HTTPStrue
TIMEZONEUTC
INSTALL_MICROfalse

full reference → docs/configuration

Stop forgetting step 4.

Ship a hardened box every time. MIT licensed. Audited by ShellCheck on every push.