One command to take a freshly provisioned Debian/Ubuntu VPS from root + password + port 22 to a hardened baseline: SSH on a non-default port with key-only login, default-deny firewall, fail2ban, kernel hardening, audit trail.
Written because "I configured the server by hand" is how people ship servers with port 22 open, root login enabled and no firewall. This is the same baseline applied the same way on every host — and it can prove it afterwards.
- Idempotent — running it twice changes nothing the second time and exits
0. - Fail-closed — if it cannot find a non-root account with a working SSH key it refuses to disable password authentication (exit
3) instead of locking you out. - Auditable —
--checkreports PASS/FAIL/WARN per control and exits9if anything is still open. It is the same control list it just implemented, so it is not a rubber stamp.
$ sudo ./harden.sh --port 22022 --user deploy -y
[i] platform: Debian GNU/Linux 12 (bookworm)
[i] sshd currently listening on port 22 -> target 22022
...
[ok] done in 41s — log /var/log/harden-20260921-214634.log
| Area | Action |
|---|---|
| Packages | apt update && apt upgrade; installs openssh-server ca-certificates curl + ufw + fail2ban python3-systemd |
| SSH port | Moves the listener (default 22022), keeps the old port open until the new one is verified, then closes it with --close-old-port |
| SSH auth | PermitRootLogin no, PasswordAuthentication no, PermitEmptyPasswords no, MaxAuthTries 3, KbdInteractiveAuthentication no — in a drop-in, never by editing the vendor file |
| SSH crypto | Modern ciphers/KEX/MACs, AllowTcpForwarding no, X11Forwarding no, AllowAgentForwarding no (--legacy-crypto keeps the defaults for old clients) |
| Firewall | ufw default deny incoming / allow outgoing, then only 80, 443 and the new SSH port (optionally restricted to a CIDR with --allow-cidr) |
| Brute force | fail2ban with an sshd jail (maxretry 4, bantime 1h) wired to the journal via python3-systemd |
| Kernel | sysctl drop-in: rp_filter, no ICMP redirects/accepts, TCP syncookies, randomize_va_space=2, kptr_restrict=2, fs.suid_dumpable=0 |
| Evidence | Every run writes /var/log/harden-<timestamp>.log and backs up every file it touches to /var/backups/harden-<timestamp>/ |
Every change is written to a new drop-in file (/etc/ssh/sshd_config.d/00-hardening.conf, /etc/sysctl.d/99-hardening.conf, /etc/fail2ban/jail.d/sshd-hardening.local, /etc/apt/apt.conf.d/99-hardening-upgrades). Nothing is edited in place, so the diff is reviewable and dpkg upgrades never fight you over a vendor file.
git clone https://github.com/<you>/linux-vps-hardening-script
cd linux-vps-hardening-script
# 1. Look before you leap: prints every action, changes nothing
sudo ./harden.sh --port 22022 --user deploy --dry-run
# 2. Audit the host as it is now
sudo ./harden.sh --check # exit 9 = controls still failing
# 3. Apply
sudo ./harden.sh --port 22022 --user deploy -y
# 4. Re-audit and keep the output as evidence
sudo ./harden.sh --check | tee reports/audit-$(hostname)-$(date +%F).txtRun it straight from cloud-init so the box is never reachable in its default state — see examples/cloud-init.yaml.
--port N new SSH port (default: 22022)
--user NAME non-root account that keeps SSH access
--allow-cidr CIDR restrict the SSH port to this CIDR (repeatable)
--check audit only: report PASS/FAIL, change nothing
--dry-run print what would be done, change nothing
-y, --yes do not ask for confirmation
--no-fail2ban skip fail2ban
--no-ufw skip the firewall step
--close-old-port close the previous SSH port in the firewall
--auto-updates install and configure unattended-upgrades
--legacy-crypto keep sshd's default ciphers/KEX (old clients, e.g. WinSCP)
--skip-upgrade do not run apt upgrade (only install missing packages)
Exit codes: 0 ok · 1 fatal/runtime error · 2 bad usage · 3 refused (would lock you out) · 4 unsupported platform · 9 audit found FAIL controls. The distinct codes are deliberate: cron, Ansible or a pipeline can tell "the host is insecure" (9) apart from "the script broke" (1).
tests/smoke.sh boots a privileged systemd container on Debian 12, installs the script and exercises it end to end — no mocks, no fake sshd -T parser. Real evidence from a run on this repo:
$ make test
== 1) audit BEFORE hardening (expect FAILs, exit 9) -> 6 PASS / 6 FAIL, rc=9
== 3) apply hardening -> rc=0
== 5) the running sshd answers on the new port -> TCP open, ssh deploy@22022 works
== 6) idempotency: second run is a no-op -> rc=0, "unchanged: ...", 0 FAIL
== 7) audit AFTER hardening -> 15 PASS / 0 FAIL / 1 WARN, rc=0
== 8) fail-closed: refuses to disable passwords with no admin key -> rc=3, port untouched
== 9) invalid CIDR / invalid port rejected -> rc=2
The single WARN is honest: kernel.kptr_restrict and kernel.randomize_va_space are host-global, so inside a container they cannot be written at all — the audit reports the config as present but flags the live value instead of pretending otherwise.
make test # full end-to-end suite in a throwaway container
make lint # shellcheck (if installed)
make dry-run # local dry run
make help- Fail-closed over convenient. Disabling password authentication when the admin account has no key is a one-way ticket to a locked-out server. The script checks for a non-empty
authorized_keysand aroot-prohibited account before writing the config; if it finds none, it aborts with3. - Never touch the vendor config. Drop-ins only.
sshd_config.d/00-hardening.confsorts first, so the effective config is the hardening baseline plus whatever the distro ships for everything else. - Verify the new listener before closing the old port. The port change is the classic way to brick remote access; the script checks the new port is actually listening, and only
--close-old-portremoves the old rule. - No lock-in, no agent. Bash + the distro's own tooling. It runs from cloud-init, from Ansible, from a Makefile, or by hand — this same script is what the playbook/template calls, so there is one implementation of the baseline instead of two that drift.
- Bash, not Ansible, because a 650-line script that a junior can read and that runs before any config-management agent is installed is the shortest path to a hardened box; Ansible earns its place when you have many hosts and want inventory, idempotency across playbooks, and drift detection. Both work here —
examples/shows the cloud-init path.
Debian 11/12 or Ubuntu 20.04+ (systemd, apt). --check needs only bash; applying needs root. Tested on Debian 12 in a privileged container and used on VPS instances behind Cloudflare-proxied DNS.
docs/WHAT-IT-CHANGES.md— every file it writes, and whydocs/DESIGN.md— idempotency, the trust model, exit codes, the test strategyreports/example-audit.txt— real--checkoutput
MIT licensed. See LICENSE.