Harden SSH on a DigitalOcean Debian 13 (Trixie) VPS
A complete walkthrough your team can run on any fresh DigitalOcean server. When you are done, root login is off, passwords are off, only named users can log in on a non-standard port, ufw and the DigitalOcean Cloud Firewall are both default-deny, and fail2ban is watching the auth log. The exact setup every production server needs before anything else goes on it — and the one thing every team puts off until it is too late.
Why this matters before you ship anything
An un-hardened SSH service on a public IP is probed within minutes of the first boot. If password auth is on, the probes become brute-force attempts; if root login is on, a single leaked or weak credential owns the box. None of this is theoretical — it is what every VPS provider's abuse team spends most of its day dealing with.
Hardening SSH properly is the kind of work that sits in the blind spot of a small software team. No single engineer owns it, nobody is paid to think about it on a Tuesday, and the failure mode is silent until it is catastrophic. This guide closes that gap in about thirty minutes, and the DeployCrate link at the bottom closes it in about thirty seconds.
Prerequisites
- A DigitalOcean account and a Debian 13 (Trixie) server reachable at a public IP.
- An ed25519 SSH keypair on your workstation. If you do not have one yet, generate it with
ssh-keygen -t ed25519. - Your public key attached to the server during creation, so the first login works over keys rather than passwords.
DigitalOcean droplets include the do-agent and DO monitoring hooks by default. Keep them if you plan to use DigitalOcean monitoring; otherwise they can be removed after provisioning.
Part 1. First login and full patch
The default user on a fresh DigitalOcean image is root. Log in, update the package index, apply every available upgrade, install the tools the rest of this guide uses, and reboot so any kernel updates take effect.
ssh root@YOUR_SERVER_IP apt-get update apt-get -y dist-upgrade apt-get -y install ufw sudo curl ca-certificates fail2ban rebootReconnect once the reboot completes. A patched base system is the single largest free security win you have; skipping it makes every later step weaker.
Part 2. Create a sudo admin user
Root logins are an auditing problem on top of a security problem: you cannot tell which operator did what when everyone shares a login. Create a named admin user with passwordless sudo (so non-interactive deploys work), copy your SSH key, and lock down the ssh directory.
adduser --disabled-password --gecos "" admin usermod -aG sudo admin install -d -m 0700 -o admin -g admin /home/admin/.ssh cp /root/.ssh/authorized_keys /home/admin/.ssh/authorized_keys chown admin:admin /home/admin/.ssh/authorized_keys chmod 0600 /home/admin/.ssh/authorized_keys echo "admin ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-admin chmod 0440 /etc/sudoers.d/90-adminFor a team with multiple operators, repeat the adduser and SSH key steps for each person — one named account per human, none shared. That way audit logs identify who ran what, and off-boarding is as simple as removing that user's key.
Part 3. Rewrite sshd_config
The stock Debian sshd_config is fine for a home lab and too lax for a production VPS. Replace it with an opinionated config: key-only, non-root, non-standard port, strict modes, and an explicit AllowUsers allowlist that refuses any account not named.
sudo tee /etc/ssh/sshd_config >/dev/null <<'EOF' Port 2222 AddressFamily inet Protocol 2 LogLevel VERBOSE LoginGraceTime 30 StrictModes yes PubkeyAuthentication yes PermitRootLogin no PasswordAuthentication no ChallengeResponseAuthentication no PermitEmptyPasswords no X11Forwarding no PrintMotd no UsePAM yes AllowUsers admin MaxAuthTries 3 MaxSessions 2 ClientAliveInterval 300 ClientAliveCountMax 2 Subsystem sftp /usr/lib/openssh/sftp-server EOF sudo sshd -t sudo systemctl restart sshA word on each major choice. Moving off port 22 is not real security on its own, but it cuts log volume from internet-wide scanners by more than ninety percent, which in turn makes real authentication failures easier to spot. AllowUsers is a strict allowlist — a future deploy that adds another account will be refused at the SSH layer until the config is updated, which is exactly the behaviour we want. MaxAuthTries 3 combined with PasswordAuthentication off means an attacker gets three key-based attempts before the connection is dropped.
Before you log out of the root session, open a second terminal and confirm ssh -p 2222 admin@YOUR_SERVER_IP works. If it does not, fix the config from the still-open root session. Never trust an SSH change until a second, independent session has validated it.
Part 4. Host firewall with ufw
ufw is a thin wrapper around nftables. The goal is a default-deny firewall that allows only SSH on the new port and any public application ports you intend to expose. Order matters: add the SSH allow rule before enabling ufw, or you lock yourself out.
sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow 2222/tcp comment 'ssh' sudo ufw allow 80,443/tcp comment 'http https' sudo ufw --force enable sudo ufw status verbosePart 5. Cloud firewall at the DigitalOcean edge
Attach a DigitalOcean Cloud Firewall to the droplet from the control panel or via doctl. Configure it alongside ufw on the host.
Host-level ufw and the DigitalOcean Cloud Firewall are complementary, not redundant. The cloud firewall blocks traffic before it reaches your VM's network stack, which is both cheaper and safer. Configure both with the same allowlist: SSH on 2222 from your office range or VPN, plus 80 and 443 from anywhere once you are ready to go public.
Part 6. fail2ban for the auth log
fail2ban watches the SSH auth log and bans source IPs that hit a failure threshold. It is not a replacement for key-only auth — it is a traffic-shaping tool that keeps brute-force noise out of your logs and your kernel's connection table. Install it and pin the sshd jail at the new port.
sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF' [sshd] enabled = true port = 2222 filter = sshd logpath = /var/log/auth.log maxretry = 3 bantime = 3600 findtime = 600 EOF sudo systemctl enable --now fail2ban sudo fail2ban-client status sshdA one-hour ban after three failed attempts in ten minutes is a sensible default. Increase bantime aggressively if you see repeat offenders in sudo fail2ban-client status sshd — there is no reason to be generous to the same subnet twice.
Part 7. Operational checklist for the team
A hardened SSH setup is only useful if it stays hardened. These are the recurring tasks that turn a one-off setup into a policy any team can actually follow.
- Key rotation. Every operator rotates their ed25519 key at least annually, or immediately on any incident. Add the new key, verify login, then remove the old line from authorized_keys.
- Off-boarding. When a teammate leaves, delete their line from every server's authorized_keys the same day. A named-account policy (Part 2) makes this a one-line change per host.
- Allowlist hygiene. Every new operator account means a new entry in AllowUsers plus an
sshd -t && systemctl reload ssh. Put it in the runbook; do not let it drift. - Audit with journalctl.
journalctl -u ssh --since "-24h"gives a full day of auth activity. Spot-check weekly; automate alerting when you grow past a handful of servers. - Recovery plan. Document how you regain access if you lock yourself out: the provider's rescue console or VNC, the root disk mount path, and the sshd_config line that needs editing. Write it down before you need it.
Troubleshooting
I restarted sshd and now I cannot connect on the new port
ssh -vvv -p 2222 admin@IP prints the exact handshake step where it fails.Permission denied (publickey) after rewriting sshd_config
fail2ban shows zero bans even under obvious brute force
sudo systemctl reload fail2ban.