Hardening DigitalOcean Debian 13 (Trixie) SSH ufw fail2ban

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 reboot

Reconnect 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-admin

For 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 ssh

A 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 verbose

Part 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 sshd

A 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
Check three things in order: ufw status actually shows the allow rule for your new port, the DigitalOcean Cloud Firewall allows that port from your IP, and your client is using the right port flag. ssh -vvv -p 2222 admin@IP prints the exact handshake step where it fails.
Permission denied (publickey) after rewriting sshd_config
Almost always a missing line in AllowUsers. If you added a new operator account but forgot to add it to the allowlist, sshd refuses the connection even when the key is valid. Add the user, run sshd -t, then reload.
fail2ban shows zero bans even under obvious brute force
The sshd jail is pinned to port 22 in the default config. If you moved SSH to a non-standard port, fail2ban reads the right log but the default filter ignores connections to the old port. The port directive in jail.local fixes it; reload with sudo systemctl reload fail2ban.
I am locked out of the server
Do not reprovision. Use the DigitalOcean rescue console or web VNC to get a root shell, mount the root disk if the rescue boots a separate environment, edit sshd_config back to a known-good state, restart ssh, and verify from a fresh client session. Every provider offers this path; it is worth finding it in the console before you ever need it.

FAQ

Do we really need to move off port 22?
Not for the security itself — key-only auth is what keeps attackers out. The reason to move is log hygiene: on port 22 your auth log is a firehose of scanner noise that buries real signals. On a non-standard port the volume drops by an order of magnitude and real auth failures become visible.
Is fail2ban still useful if password auth is off?
Yes. It stops scanner traffic at the kernel level instead of burning CPU on cryptographic handshakes that will always fail. It also shrinks the auth log, which makes the occasional real incident easier to spot.
What about MFA on SSH?
For most small teams, ed25519 keys stored on a hardware token or a password-protected keyring are already a second factor. If you need true MFA on top, look at pam_oath or the provider's identity-aware proxy. Worth the complexity only once the team is larger than a handful of operators.
How does this fit with the full deploy walkthrough?
This guide is Parts 1 through 4 of the full deploy guide, extracted and expanded. If you are going to follow the binary-plus-systemd or docker-plus-systemd guide end-to-end, you can do this one first and skip those sections when you get there.
DeployCrate

Skip the sysadmin hire

The walkthrough above is the right way to harden a server. It is also the kind of work that small software teams defer, half-finish, and forget — because nobody owns it and nothing breaks the day you skip it. DeployCrate exists so your team does not have to own it.

Connect your DigitalOcean credentials, click Provision, and the platform runs a vetted set of scripts that produce exactly the configuration on this page: a named admin user per operator, hardened sshd, ufw with the right allowlist, fail2ban tuned to the new port, and an audit trail of what was applied when. When a teammate leaves, one click rotates their access across every server your team owns.

Every script is open for inspection. The SSH configuration above mirrors our ssh_hardening.sh, the firewall setup mirrors host_safety.sh, and fail2ban is configured by fail_to_ban.sh. You are not handing over control — you are skipping the step where a small team pretends it has a sysadmin it has not hired yet.

Related guides