A fresh Rocky Linux 10 server already runs sshd, so the job is configuring it, not installing it. The defaults are reasonable but loose: password logins are on unless your image switched them off, root may log in with a key, X11 forwarding is enabled, and these settings sit in three files you have probably never opened. The trap is the load order of /etc/ssh/sshd_config.d/, where the first value sshd reads wins and a hardening file with the wrong name changes nothing.
This guide shows how to configure SSH server on Rocky Linux 10 and AlmaLinux 10 the way we run it. You check the service and firewalld, copy your key, then put every change in one drop-in file you audit with sshd -T. Every command ran on Rocky Linux 10.2 with OpenSSH 9.9p1 and firewalld 2.4.3 in October 2026, and we checked the AlmaLinux 10.2 packages in a container, where one default differs.
Prerequisites
sshd itself is light, so sizing is not the question here. What matters is that you can get back in if a change goes wrong, because several steps below can lock you out of a remote machine:
- Rocky Linux 10 or AlmaLinux 10, minimal or server install
- a user in the
wheelgroup withsudo - console access (hypervisor, cloud serial console or IPMI) as a fallback
- a Linux or macOS workstation with the OpenSSH client
Keep one SSH session open the whole time you work and test every change from a second terminal. An open session survives a restart of sshd, so it is your way back in until you have proved the new settings work. If SSH is new to you and you want the protocol, tunnels and agent forwarding explained properly, the SSH books we recommend go much further than one server guide can.
Check that sshd is installed and running
If you reached the server over SSH in the first place, openssh-server is installed and sshd.service is enabled, and the Rocky 10 cloud image our VM came from had both. Confirm the packages first, which also tells you the build you are configuring.
rpm -q openssh-server openssh-clients
Our Rocky 10.2 server first reported the -23 build, then the -28 build below after a dnf transaction pulled in updates. The status output further down was captured before that update, which is why its timestamps come first:
openssh-server-9.9p1-28.el10_2.rocky.0.1.x86_64
openssh-clients-9.9p1-28.el10_2.rocky.0.1.x86_64
If rpm reports package openssh-server is not installed, you are on a stripped-down image. Install it and enable the service in one go.
sudo dnf install -y openssh-server
sudo systemctl enable --now sshd
Now check the service. You want enabled on the Loaded line, active (running) below it, and the listener process at 0 of 10-100 startups, which counts unauthenticated connections against the MaxStartups limit:
systemctl status sshd --no-pager
Below is the top of the status output on our server, with the journal lines left out.
● sshd.service - OpenSSH server daemon
Loaded: loaded (/usr/lib/systemd/system/sshd.service; enabled; preset: enabled)
Active: active (running) since Sun 2026-10-18 03:13:29 UTC; 22s ago
Invocation: dd1cc7b091084692adf7777d31a748cd
Docs: man:sshd(8)
man:sshd_config(5)
Main PID: 1058 (sshd)
Tasks: 1 (limit: 10820)
Memory: 5.5M (peak: 6.8M)
CPU: 124ms
CGroup: /system.slice/sshd.service
└─1058 "sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups"
sshd listens on port 22 on every IPv4 and IPv6 address by default. Run ss with sudo, because it only names processes owned by other users when it runs as root.
sudo ss -tlnp | grep sshd
Two listeners owned by one process is the healthy result. A single 0.0.0.0:22 line means IPv6 is off on the host, which is fine if your clients use IPv4:
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1058,fd=7))
LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=1058,fd=8))
Note the port and the PID here. Every later check refers back to this listener, and a reload keeps the same PID.
Confirm SSH is open in firewalld
Check whether firewalld is there before assuming it is. The Rocky 10 cloud image our VM was built from shipped without it, and sudo firewall-cmd --state answered sudo: firewall-cmd: command not found. If you get the same, install and start it now. Its default public zone allowed the ssh service from the first start on our server, so this step did not cut the open session:
sudo dnf install -y firewalld
sudo systemctl enable --now firewalld
Either way, list the active zone and look for ssh on the services line.
sudo firewall-cmd --list-all
The ssh service is defined as 22/tcp and nothing else, which firewall-cmd --info-service=ssh confirms. If you later move sshd to another port, this service entry stops covering it. Our zone looked like this.
public (default, active)
target: default
ingress-priority: 0
egress-priority: 0
icmp-block-inversion: no
interfaces: eth0
sources:
services: cockpit dhcpv6-client ssh
ports:
protocols:
forward: yes
masquerade: no
forward-ports:
source-ports:
icmp-blocks:
rich rules:
If ssh is missing from that line, add it with sudo firewall-cmd --permanent --add-service=ssh followed by sudo firewall-cmd --reload. Our firewalld guide for Rocky Linux 10 covers zones and rich rules if you want SSH reachable only from an admin network.
Copy your SSH key to the server
Key login has to work before you switch passwords off, otherwise the hardening step locks you out. ssh-copy-id needs one working password login, which a cloud image may not give you: our VM’s 50-cloud-init.conf set PasswordAuthentication no, and a password copy to a new account fails there with Permission denied (publickey,...). On a cloud image, keep using the key cloud-init installed for the default user and set ADMIN_USER to that account below, so the copy step has nothing left to do. For the captures below we deleted the cloud-init file to mirror an ISO install, so from here on the server had two drop-ins, not three. Run these on your workstation, not on the server. Set the server address and the account you log in as once, so the later commands work as written.
export HOST_IP="10.0.1.50"
export ADMIN_USER="deploy"
Generate an Ed25519 key if you don’t have one yet. Give it a passphrase when asked; our lab key was created without one only because a script drove it, and the passphrase guide shows how to add one to an existing key:
ssh-keygen -t ed25519 -C "josphat@workstation"
Then copy the public key into the account’s authorized_keys. This is the last time you type that account’s password over SSH.
ssh-copy-id -i ~/.ssh/id_ed25519.pub "${ADMIN_USER}@${HOST_IP}"
After the password prompt, a successful copy ends on Number of key(s) added: 1. If it prints All keys were skipped because they already exist on the remote system, the key was already there and you can move on:
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/josphat/.ssh/id_ed25519.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
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.
Prove it from the workstation before going further. The command must return the account name and hostname without asking for a password.
ssh "${ADMIN_USER}@${HOST_IP}" 'whoami; hostname'
ssh-copy-id also gets the SELinux label right, which is why we use it rather than pasting keys by hand. Our new authorized_keys came out as ssh_home_t with mode 600. A file restored from a tarball or copied in as root can end up with the wrong context. With SELinux enforcing, that can stop sshd from reading it; restorecon -Rv ~/.ssh puts the label back. On a cloud image there is usually nothing to copy, because cloud-init already put your key on the default user.
Read the sshd_config.d load order
On Rocky 10 the main /etc/ssh/sshd_config sets almost nothing. Its first active line is Include /etc/ssh/sshd_config.d/*.conf, and the real defaults live in drop-in files. List them:
sudo ls -l /etc/ssh/sshd_config.d/
Our cloud VM had three. The two redhat files come from the openssh-server package, and the third was written by cloud-init:
-rw-------. 1 root root 412 May 19 00:00 40-redhat-crypto-policies.conf
-rw-------. 1 root root 26 Oct 18 03:13 50-cloud-init.conf
-rw-------. 1 root root 307 May 19 00:00 50-redhat.conf
Each file has one job.
40-redhat-crypto-policies.conf: includes the crypto policy back end50-cloud-init.conf:PasswordAuthentication no(cloud images only)50-redhat.conf:UsePAM yes,X11Forwarding yes, GSSAPI on,PrintMotd no
sshd reads these in lexical order, and for most keywords the first value it reads is the one it keeps. That is the opposite of what most people expect from a “later file overrides earlier file” layout. We proved it on the lab server: a 99-late.conf containing X11Forwarding no passed sshd -t but sshd -T still reported x11forwarding yes, because 50-redhat.conf got there first. Renaming the same file to 10-early.conf flipped it to no. So your own file needs a number below 50 to beat the package and cloud-init files, and you check the result with sshd -T, never by reading the files.
AlmaLinux 10 follows the same layout with the same two package files. The official almalinux:10 container image we checked carried one extra file, 25-permitrootlogin.conf, containing PermitRootLogin yes and owned by no package, so sshd -T there reported permitrootlogin yes where Rocky reports without-password. Any file in this directory that you did not write deserves a look. A hardening file numbered 10 sorts ahead of every package and cloud-init file, but not ahead of everything: see the installer file below.
The file that can still beat it comes from the installer. If you ticked “Allow root SSH login with password” during an ISO install, Anaconda writes /etc/ssh/sshd_config.d/01-permitrootlogin.conf containing PermitRootLogin yes, and 01 sorts before 10. We created that file on the lab server next to our hardening file: sshd -T reported permitrootlogin yes while it existed and permitrootlogin no once it was gone. If the file is on your server, delete it before you harden:
sudo rm -f /etc/ssh/sshd_config.d/01-permitrootlogin.conf
The -f flag keeps the command quiet on servers that never had the file, so it is safe to run everywhere. The change takes effect at the reload in the next section.
Harden sshd with a drop-in file
One check comes first, because this file turns root logins off. Prove that the account you log in as can still become root, so you don’t lose your only admin path when root goes away. From the workstation:
ssh -t "${ADMIN_USER}@${HOST_IP}" sudo -v
It should ask for the account’s password and return without an error; our deploy account, a member of wheel, exited 0 when we fed it the password with sudo -S -v from a script. If it prints is not in the sudoers file, or the account has no password and no passwordless sudo rule, fix that over your current session before going on.
Put every change in one file instead of editing sshd_config. An update to openssh-server leaves an .rpmnew beside an edited sshd_config that you then merge by hand. Your own drop-in belongs to no package, so updates never touch it, and one file is easy to diff, copy to the next server or push with Ansible. Create it.
sudo vim /etc/ssh/sshd_config.d/10-hardening.conf
Add these settings, which are the exact file our server ran.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
Root logins and passwords go away entirely, and keyboard-interactive goes with them because PAM would otherwise still prompt for a password through it. MaxAuthTries 3 and LoginGraceTime 30 cut how long a bot can hold a connection open, and the two ClientAlive lines disconnect clients that stop answering sshd’s keepalive probes, after about ten minutes. An idle session whose client still answers stays connected. Leave out X11Forwarding no if you really run graphical apps over SSH. Everything here is a default from the sshd_config manual turned stricter, so nothing in it can stop sshd from starting.
Test the syntax before applying anything. No output and exit code 0 means the whole configuration parsed.
sudo sshd -t
Apply it with reload, not restart. A reload re-reads the configuration in the running daemon, while each restart counts against systemd’s start limit for sshd.service, which is the systemd default of 5 starts in 10 seconds. We tested it with a loop of six back-to-back restarts. The fourth failed with start of the service was attempted too often, because earlier starts were still inside the 10 second window, and sshd stayed down with only the sessions already open still working:
sudo systemctl reload sshd
Now ask sshd what it will actually enforce. sshd -T prints the merged result of every file, which is the only view that accounts for load order:
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|maxauthtries|logingracetime|x11forwarding|clientaliveinterval|clientalivecountmax) '
All eight values match the file, so nothing earlier in the chain is overriding it. On your server, check permitrootlogin as carefully as the password line, because that is the value an installer drop-in overrides.
logingracetime 30
maxauthtries 3
clientaliveinterval 300
clientalivecountmax 2
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
x11forwarding no
Finally, test from your second terminal that the doors are really shut. Forcing password authentication and trying root should both be refused, while your key still logs in:
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password,keyboard-interactive "${ADMIN_USER}@${HOST_IP}" true
ssh -o BatchMode=yes "root@${HOST_IP}" true
Both attempts exited with code 255, and the method list in the error no longer contains password. If you still see password there, or root gets in, another drop-in is winning; run sshd -T again and look for a file numbered below yours, such as the installer’s 01-permitrootlogin.conf:
[email protected]: Permission denied (publickey,gssapi-keyex,gssapi-with-mic).
[email protected]: Permission denied (publickey,gssapi-keyex,gssapi-with-mic).
Both refusals are what you want. Your own key login from the second terminal should still work at this point, so keep that session open until the crypto policy check below is done too.
Check the system crypto policy
Ciphers, MACs and key exchange algorithms on Rocky 10 come from the system-wide crypto policy, not from sshd’s own defaults. The 40-redhat-crypto-policies.conf file pulls in /etc/crypto-policies/back-ends/opensshserver.config, and because it sorts early, a Ciphers line in a later file is silently ignored. Leave these keywords out of your drop-in and change the policy instead. Check which one is active.
update-crypto-policies --show
A new install prints DEFAULT. That policy already prefers post-quantum hybrid key exchange, which you can see by connecting with ssh -v and reading the negotiated algorithm:
ssh -v "${ADMIN_USER}@${HOST_IP}" true 2>&1 | grep -E 'kex: algorithm|Authenticated'
Our OpenSSH 9.9 client and server agreed on mlkem768x25519-sha256, the ML-KEM hybrid that sits first in both lists:
debug1: kex: algorithm: mlkem768x25519-sha256
Authenticated to 10.0.1.50 ([10.0.1.50]:22) using "publickey".
The stricter FUTURE policy is tempting, but measure who connects first. Switching to it cut sshd’s key exchange list from 12 algorithms to the 3 ML-KEM hybrids, so any client without ML-KEM support is refused before authentication even starts. We simulated an older client by limiting ours to curve25519-sha256, and it failed like this:
Unable to negotiate with 10.0.1.50 port 22: no matching key exchange method found. Their offer: mlkem768x25519-sha256,mlkem768nistp256-sha256,mlkem1024nistp384-sha384,ext-info-s,[email protected]
The same client connected again as soon as the policy went back to DEFAULT. ML-KEM key exchange arrived in OpenSSH 9.9, so any client older than that is left outside, which includes the stock clients on Debian 12 and Ubuntu 24.04 and a lot of network gear and backup appliances. We’d stay on DEFAULT unless every client is known to be current. The policy is also system-wide, not an sshd setting. FUTURE tightens TLS for dnf, curl and every other client on the box, rejects RSA keys shorter than 3072 bits and SHA-1 signatures, and the tool itself printed that a reboot is recommended. If you do switch, the command is sudo update-crypto-policies --set FUTURE, followed by a reboot or at least a restart of sshd, and you test it from a client you still have open. To change only SSH, look at a custom subpolicy instead.
Use the built-in brute-force penalties
OpenSSH 9.8 added PerSourcePenalties, and Rocky 10’s sshd has it on by default. Each connection that fails authentication earns its source address a penalty of 5 seconds, and once the penalty passes a 15 second minimum, sshd refuses new connections from that address until it expires. That is a small fail2ban built into the daemon, with no log parsing and nothing extra to install. Check the active values.
sudo sshd -T | grep ^persourcepenalties
These are the stock values. authfail:5 is the per-failure penalty, min:15 is the threshold before blocking starts, and max:600 caps any one address at ten minutes:
persourcepenalties crash:90 authfail:5 noauth:1 grace-exceeded:10 refuseconnection:10 max:600 min:15 max-sources4:65536 max-sources6:65536 overflow:permissive overflow6:permissive
We tested it by hammering the server with a key it did not know. Attempts one to four got the normal Permission denied (publickey,...). The fifth, in the same second, got Connection reset by 10.0.1.50 port 22, and the journal logged the reason:
sudo journalctl -u sshd --since "-2min" --no-pager | grep -iE "penalt|drop" | tail -8
Attempts five to eight were all refused, and each left one drop connection line. Source and destination are the same address because we ran the test from the server against its own IP.
Oct 18 03:22:12 web01 sshd[49218]: drop connection #0 from [10.0.1.50]:41688 on [10.0.1.50]:22 penalty: failed authentication
Oct 18 03:22:12 web01 sshd[49218]: drop connection #0 from [10.0.1.50]:41692 on [10.0.1.50]:22 penalty: failed authentication
Oct 18 03:22:12 web01 sshd[49218]: drop connection #0 from [10.0.1.50]:41694 on [10.0.1.50]:22 penalty: failed authentication
Oct 18 03:22:12 web01 sshd[49218]: drop connection #0 from [10.0.1.50]:41706 on [10.0.1.50]:22 penalty: failed authentication
The penalty is per address, not per key, so it hits you too. In a timed rerun, four failed connections from the server’s own address locked out our correct key for about 20 seconds: refused from 05:47:09.8 until it was accepted at 05:47:30.2, which is four 5-second penalties. A whole office behind one NAT address shares that fate, so add your admin network to the hardening file with a line such as PerSourcePenaltyExemptList 10.0.1.0/24. One related trap is not a penalty at all. An agent holding several keys offers them one by one inside a single connection, and with MaxAuthTries 3 from our file sshd cuts that connection with Too many authentication failures before a fourth key is tried; set IdentitiesOnly yes and an IdentityFile for this host in the client config. For long bans across many failures, a dedicated tool still does more; our Fail2ban setup for Rocky Linux 10 covers that, but on a key-only server the built-in penalties are often all you need.
Move SSH to another port
Changing the port is optional, and it takes three coordinated changes on Rocky 10 rather than one. SELinux only lets sshd bind to ports labelled ssh_port_t, and our server listed exactly one:
sudo semanage port -l | grep -w ssh_port_t
Only port 22 carries the label on a new install.
ssh_port_t tcp 22
So a new Port line needs a semanage port -a label first, then a firewalld rule for the new port, and only then the sshd change, all while port 22 stays open. Getting that order wrong is the classic way to lock yourself out, so it has its own walkthrough: change the SSH port with SELinux enforcing. It covers the exact error sshd logs when the label is missing and how to roll back. If SELinux blocks anything else here, our SELinux troubleshooting guide shows how to read the AVC denials.
Audit the finished sshd configuration
Before you close the session you have kept open, run through the checks below. Each is one command, and each one maps to a step above, so a failure tells you exactly which step to revisit:
sudo sshd -texits 0 with no outputsudo sshd -Tshowspasswordauthentication noandpermitrootlogin nosudo ls /etc/ssh/sshd_config.d/holds no file you can’t explainupdate-crypto-policies --showprints the policy you chosesudo firewall-cmd --list-alllistssshor your custom port- a fresh key login from a second terminal succeeds
The terminal below shows the syntax check, the merged settings, the crypto policy and the listeners on our finished Rocky 10 server, which covers the first, second and fourth checks:

If sshd ever refuses to start after an edit, go to the console you set aside in the prerequisites. sudo sshd -t names the file and line it rejects. If the unit hit its start limit as ours did, sudo systemctl reset-failed sshd followed by sudo systemctl start sshd brings it back. We recovered our lab server with exactly those two commands from a session that was already open. With the hardening file in place, the SSH client config file on your workstation is the next thing worth setting up, so the address and user stop living in your shell history.