Whether Debian 13 has an SSH server depends on how it was installed. A cloud image arrives with openssh-server running and passwords off. An ISO install with the “SSH server” box left unticked has no server at all. The stock defaults still allow password logins, Debian 13 ships no firewall rules, and the stock fail2ban jail counted none of our six key-probing attempts until we changed one setting.
This guide covers how to install SSH server on Debian 13, read its real defaults, move to key-only logins with a drop-in file, open port 22 in nftables and decide whether fail2ban is worth adding. Ubuntu readers have a separate Ubuntu SSH server guide, and client-side work (keys, the agent, ~/.ssh/config) lives in our companion guide to using SSH and SSH keys on Debian. Everything below was run on Debian 13.7 with OpenSSH 10.0p1, nftables 1.1.3 and fail2ban 1.1.0 in October 2026.
Prerequisites
An SSH server barely registers on CPU or memory, so the only real requirement is a way back in. The firewall and AllowGroups steps can both cut you off a remote machine if you get them wrong, so line these up before you start:
- Debian 13 (trixie), server or desktop (our post-install checklist covers the basics)
- a user with
sudo - console access through the hypervisor, IPMI or a cloud serial console
- a workstation with the OpenSSH client
Keep one session open while you work and test each change from a second terminal, because an open session survives a reload of sshd. If you’d rather understand the protocol than copy settings, our pick of the best SSH books covers keys, certificates and tunnels in more depth than any single server guide.
Install the OpenSSH server
Check first, because cloud images and most hosting templates already have it. A line starting with ii means the package is installed:
dpkg -l openssh-server
On a machine without it, the last line reads un openssh-server <none> and systemctl status ssh answers Unit ssh.service could not be found. Install the package from the console:
sudo apt update
sudo apt install -y openssh-server
The package generates three host keys and enables the service as part of the install, so there is no separate enable step on Debian. These lines from our install output show both (an excerpt):
Setting up openssh-server (1:10.0p1-7+deb13u4) ...
Creating config file /etc/ssh/sshd_config with new version
Creating SSH2 RSA key; this may take some time ...
3072 SHA256:aRxLvUG11X/UVf4DTr/h1TmlJpFqh6gGf8mg75cT10w root@web01 (RSA)
Creating SSH2 ECDSA key; this may take some time ...
256 SHA256:zGkUDrTQA4kpnxLq1HR0mf1iqta6SWoLT8sbbopvb1A root@web01 (ECDSA)
Creating SSH2 ED25519 key; this may take some time ...
256 SHA256:PS5/09VzvifcZDFNP7Eukb/nGlpMRyp8OUeg04djM8Q root@web01 (ED25519)
Creating user 'sshd' (sshd user) with UID 989 and GID 65534.
Created symlink '/etc/systemd/system/sshd.service' → '/usr/lib/systemd/system/ssh.service'.
Created symlink '/etc/systemd/system/multi-user.target.wants/ssh.service' → '/usr/lib/systemd/system/ssh.service'.
ssh.socket is a disabled or a static unit, not starting it.
Two things in there matter later. The unit is called ssh.service on Debian, with sshd.service only as an alias, so systemctl reload ssh is the command you’ll use. And ssh.socket stays disabled: Debian 13 runs a normal listening daemon by default rather than socket activation, which is why a Port change in the config is all a port move needs. Write down the Ed25519 fingerprint, because it is what your client should show on the first connection. Confirm the service:
systemctl status ssh --no-pager
You want enabled and active (running), with the listener at 0 of 10-100 startups. This is the top of ours, 13 seconds after the install:
● ssh.service - OpenBSD Secure Shell server
Loaded: loaded (/usr/lib/systemd/system/ssh.service; enabled; preset: enabled)
Active: active (running) since Sun 2026-10-18 03:47:19 UTC; 13s ago
Invocation: 3877a4bbd6a94eb386dc5894c57f2e00
Docs: man:sshd(8)
man:sshd_config(5)
Main PID: 10699 (sshd)
Tasks: 1 (limit: 2339)
Memory: 3.1M (peak: 5.4M)
CPU: 99ms
CGroup: /system.slice/ssh.service
└─10699 "sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups"
If the Active line says failed instead, run sudo sshd -t to see which configuration line sshd rejects, and journalctl -u ssh -n 20 for the rest of the story.
Read the defaults Debian ships
Reading /etc/ssh/sshd_config tells you little, because most of it is commented-out defaults. Ask sshd for the merged result instead, which is what it actually enforces:
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|x11forwarding|maxauthtries|kexalgorithms) '
Our fresh install allowed password logins for everyone except root, kept X11 forwarding on and offered ML-KEM post-quantum key exchange first, which OpenSSH 10.0 made the default:
maxauthtries 6
permitrootlogin without-password
passwordauthentication yes
kbdinteractiveauthentication no
x11forwarding yes
kexalgorithms mlkem768x25519-sha256,sntrup761x25519-sha512,[email protected],curve25519-sha256,[email protected],ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521
Cloud images differ from this. The Debian 13 image our VM booted from had edited sshd_config directly to PasswordAuthentication no and ClientAliveInterval 120, so the same command there printed passwordauthentication no. That is why you check with sshd -T on your own machine rather than trusting any guide’s list of defaults, this one included.
One detail in Debian’s file decides how you configure it. The first active line of sshd_config is Include /etc/ssh/sshd_config.d/*.conf, and sshd keeps the first value it reads for each keyword. Anything you put in a drop-in therefore beats the same keyword further down the main file, including the edits a cloud image made, and package upgrades never have to merge your changes. The directory is empty on a fresh install, which leaves the naming of your file entirely up to you.
Copy your SSH key to the server
Key login must work before passwords go away. ssh-copy-id needs one password login, so on an ISO install the account needs a password, and on a cloud image, where passwords are already off, it fails unless your workstation already has a key the server accepts (usually the one cloud-init installed for the default user). Cloud readers who need a key on another account can add it through sudo from their existing session, then run the two export lines from the next block on the workstation, log in once as that account with the key, and skip to the hardening step; these lines were not part of our lab run, and they set the owner and modes that sshd’s StrictModes checks. Replace deploy with the account, and PUBLIC_KEY_LINE_HERE with the single line from your workstation’s ~/.ssh/id_ed25519.pub:
sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
echo 'PUBLIC_KEY_LINE_HERE' | sudo tee -a /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
Everyone else: on your workstation, set the server address and the account you log in with, then copy your public key with ssh-copy-id. If you don’t have a key yet, the client guide linked above walks through creating one with a passphrase:
export HOST_IP="10.0.1.50"
export ADMIN_USER="deploy"
ssh-copy-id -i ~/.ssh/id_ed25519.pub "${ADMIN_USER}@${HOST_IP}"
After you type the account’s password, the copy should end with one key added:
Number of key(s) added: 1
Now try logging into the machine, with: "ssh -i /home/josphat/.ssh/id_ed25519 '[email protected]'"
and check to make sure that only the key(s) you wanted were added.
Log in once with the key before you continue, with ssh "${ADMIN_USER}@${HOST_IP}". If it still asks for a password, the key was not accepted, and switching passwords off now would lock that account out.
Harden sshd with a drop-in file
Our hardening file restricts logins to members of one group. That is the step most likely to lock you out, so create the group and add every account that needs SSH before the file exists. Run this on the server, logged in as the admin account you normally use: "$USER" adds that account. sshd checks group membership at login, so the change applies to new connections straight away without logging out:
sudo groupadd --system sshusers
sudo usermod -aG sshusers "$USER"
Add any other account that logs in over SSH the same way. This line is optional: we used it for our second login, deploy, so replace the name with yours or skip it. Then list the members:
sudo usermod -aG sshusers deploy
getent group sshusers
Stop here unless your own login name is on that line, because the hardening file below refuses every account that is not. If you normally log in as root, as many VPS images set you up, you are locked out twice over by PermitRootLogin no and by AllowGroups, so create a normal sudo user, add it to the group and log in as it before going further. Ours lists our login and deploy:
sshusers:x:989:josphat,deploy
Now create the drop-in.
sudo vim /etc/ssh/sshd_config.d/10-hardening.conf
Add the following, which is the exact file our server ran.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
AllowGroups sshusers
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
AuthenticationMethods publickey is the belt to the braces of the two password lines, so a later file that re-enables passwords still can’t get one through. AllowGroups refuses every account outside sshusers, including service accounts and anyone a package creates. Lower MaxAuthTries and LoginGraceTime shorten how long a scanner can hold a connection, and X11 forwarding goes because a server rarely needs it. Check the syntax, then reload rather than restart, which keeps sshd running if something is wrong:
sudo sshd -t
sudo systemctl reload ssh
sshd -t prints nothing when the configuration is valid. Confirm that sshd took every line.
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|authenticationmethods|allowgroups|maxauthtries|logingracetime|x11forwarding) '
All eight match, so nothing in the main file overrode the drop-in:
logingracetime 30
maxauthtries 3
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
x11forwarding no
allowgroups sshusers
authenticationmethods publickey
First prove the door you need still opens. From a second terminal, a key login for your admin account must succeed before you close the first session; ours, run with an echo instead of true, printed deploy key login ok straight after the reload:
ssh "${ADMIN_USER}@${HOST_IP}" true
Now try what should fail. We ran these tests on the server against its own address, which is why the log line below shows the server’s IP as the source; from a workstation you’ll see the workstation’s address. guest is a throwaway account we created outside the group with sudo adduser --disabled-password --comment "" guest. Without such an account, sshd logs Invalid user instead of the AllowGroups message:
ssh -o PubkeyAuthentication=no "${ADMIN_USER}@${HOST_IP}" true
ssh -o BatchMode=yes "guest@${HOST_IP}" true
Both get the same terse error, and the method list now holds only publickey:
[email protected]: Permission denied (publickey).
[email protected]: Permission denied (publickey).
The client can’t tell you why guest was turned away, but the server log can, which is the first place to look when a real user reports this error. Run sudo journalctl -u ssh --since "-10min" | grep "not allowed" on the server and you get the reason in plain words:
Oct 18 03:48:21 web01 sshd-session[11253]: User guest from 10.0.1.50 not allowed because none of user's groups are listed in AllowGroups
Delete the test account once you have seen the message, with sudo deluser guest. Note the process name in that line: since OpenSSH 9.8 the per-connection work runs in a separate sshd-session binary, so log searches and scripts that grep for sshd[ miss authentication messages on Debian 13. The full keyword list is in the sshd_config manual.
Open SSH in nftables
Debian 13 does not filter anything by default. Our cloud VM didn’t even have the nftables package. Once installed, its stock /etc/nftables.conf holds three empty chains and the service stays disabled. nftables is Debian’s native framework, so we use it directly here; if you prefer zones, our guide to firewalld on Debian 13 is the alternative. Install the package:
sudo apt install -y nftables
Then replace the stock ruleset with one that drops inbound traffic by default and allows SSH.
sudo vim /etc/nftables.conf
This is the ruleset our server loaded. The established,related rule is what keeps your current session alive the moment the drop policy applies, so don’t remove it:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname "lo" accept
meta l4proto { icmp, ipv6-icmp } accept
tcp dport 22 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter;
}
}
Check the file before loading it, because a syntax error in a ruleset with a drop policy is a lockout waiting to happen. nft -c parses without applying, but exit code 0 only proves the syntax, not that the rules let you in. Confirm sudo sshd -T | grep ^port prints the port in your tcp dport line before you enable it:
sudo nft -c -f /etc/nftables.conf
sudo systemctl enable --now nftables
Test both directions from the workstation. A new SSH session still connected, while a Python web server we started on port 8080 timed out from outside (curl exit code 28), which shows the drop policy is doing its job. If you move SSH to another port later, change tcp dport 22 first and reload nftables before touching sshd, and if you run fail2ban, set port = 2222 (your new port) in its sshd.local so bans cover the port sshd actually uses.
One caution before you rely on this file. Its first line, flush ruleset, wipes every nftables table on the host, so on a machine running Docker, libvirt or LXC it deletes their rules too, and the forward chain’s drop policy then blocks container and VM traffic. Use this ruleset only on a plain server, or add their chains yourself. If you lock yourself out, this is the way back from the console, and it leaves the host unfiltered until you fix the file:
sudo nft flush ruleset
sudo systemctl disable --now nftables
We ran those two lines on the lab server and SSH stayed reachable throughout, with sudo nft list ruleset printing nothing afterwards.
Add fail2ban, or skip it
On a key-only server, sshd’s built-in PerSourcePenalties (on by default since OpenSSH 9.8) already throttles addresses that keep failing, so fail2ban is optional. Add it if you want longer bans and a record of who was banned. The Debian package needs no setup to start, because its /etc/fail2ban/jail.d/defaults-debian.conf already does three things:
- enables the
sshdjail - reads the systemd journal through
backend = systemd - bans through nftables
The journal backend matters on Debian 13, since our server had no /var/log/auth.log for fail2ban to read: nothing on it installed rsyslog. Install the package.
sudo apt install -y fail2ban
We did measure a catch, though. We made six connection attempts with a key the server didn’t know, about five seconds apart, and the stock jail counted Total failed: 0. Key rejections only produce Connection closed by authenticating user deploy ... [preauth], and the default normal filter mode ignores that line. On a server with passwords off, that is nearly every attack, so the stock jail watches for an event that cannot happen. Switching the jail to aggressive mode makes it count them. Create a local jail file:
sudo vim /etc/fail2ban/jail.d/sshd.local
Aggressive mode has a cost. It also counts connections that never send an SSH banner or time out before authenticating, so load balancer health checks, monitoring probes and BatchMode runs from Ansible or CI get banned like attackers. Their addresses belong on the ignoreip line too. Add these lines, with your own admin network there:
[sshd]
mode = aggressive
maxretry = 5
findtime = 10m
bantime = 1h
ignoreip = 127.0.0.1/8 ::1 10.0.1.0/24
The ignoreip line is there because we learned it the hard way. Our first test ran without it, and after five bad keys fail2ban banned the address our workstation reached the server from. That address was a NAT gateway, so the ban took our own working SSH access down with it and the next attempt got Connection refused. We got back in through another host and lifted the ban with sudo fail2ban-client set sshd unbanip and the address. Anyone behind the same office or VPN address as an attacker shares the ban the same way. Apply the file and confirm the settings:
sudo fail2ban-client reload
sudo fail2ban-client get sshd ignoreip
The ignored list should show your network next to the loopback ranges:
These IP addresses/networks are ignored:
|- 127.0.0.0/8
|- 10.0.1.0/24
`- ::1
The finished server, with all three services active, the hardening values in force and the one test ban on record, looks like this:

fail2ban and nftables need one more rule of thumb, because the flush ruleset line at the top of /etc/nftables.conf runs every time nftables restarts. We banned a test address, restarted nftables, and fail2ban’s inet f2b-table was gone while fail2ban-client still listed the address as banned, so the ban was no longer enforced. Restarting fail2ban put the table and the address back. Whenever you restart or reload nftables, follow it with sudo systemctl restart fail2ban.
Watch bans as they happen in /var/log/fail2ban.log, where each counted failure appears as a Found line and the fifth one in ten minutes triggers a Ban.
Errors we hit on Debian 13
Each of these came up while building the server above, and each points somewhere different.
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED
We purged and reinstalled openssh-server to capture a clean install, and the package generated new host keys. Our workstation still had the old key in known_hosts, so the next login failed with exit code 255:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that a host key has just been changed.
The fingerprint for the ED25519 key sent by the remote host is
SHA256:PS5/09VzvifcZDFNP7Eukb/nGlpMRyp8OUeg04djM8Q.
That is the top of the message. Below it, ssh names the offending line in your known_hosts and prints the exact ssh-keygen -R command to remove it, then finishes with the two lines you’ll most likely search for:
Host key for 10.0.1.50 has changed and you have requested strict checking.
Host key verification failed.
Only clear the old entry once you know why the key changed. Here the fingerprint matched the Ed25519 line from the install output, so it was safe to run ssh-keygen -R "${HOST_IP}" on the workstation and connect again. A reinstall, a rebuilt VM or an IP that moved to another machine all explain it; a key that changed for no reason you know of does not. An in-place upgrade from Debian 12 keeps the existing host keys, so the warning is not expected after one; if you see it anyway, compare the fingerprint against sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the console before removing anything.
Permission denied (publickey)
After hardening, this one error covers several causes: the wrong key, a user outside sshusers, or a client still trying passwords. The client never says which. Check journalctl -u ssh on the server, where the not allowed because none of user's groups are listed in AllowGroups line names the group problem directly, and fix it with usermod -aG sshusers.
ssh: connect to host 10.0.1.50 port 22: Connection refused
Normally this means sshd is not running, but on our server it meant fail2ban had banned us: its nftables rule rejects banned addresses with icmp port-unreachable, which the client reports as a refused connection. If systemctl is-active ssh says active on the console, run sudo fail2ban-client status sshd and look for your address under Banned IP list before you debug anything else.