Installing OpenSSH Server
On Debian and Ubuntu, the server package is openssh-server. On Red Hat family distros it is openssh-server as well, but the service name differs slightly and firewalld requires an explicit rule.
After installing, enable the service so it survives reboots, then verify the daemon is listening on the expected port before you touch anything else.
# Debian / Ubuntu
sudo apt update && sudo apt install -y openssh-server
sudo systemctl enable --now ssh
ss -tlnp | grep sshd
# Rocky / AlmaLinux / RHEL
sudo dnf install -y openssh-server
sudo systemctl enable --now sshd
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --reload
ss -tlnp | grep sshd
Generating SSH Key Pairs
RSA 2048-bit keys are obsolete for new deployments. Use Ed25519, which is faster, smaller, and based on a curve with no known parameter backdoors. If you are interfacing with legacy systems that do not support Ed25519, use RSA 4096 at minimum.
Generate the key on the client machine, never on the server. The private key must never leave the machine it was generated on.
The -C flag attaches a comment, which is useful when managing authorized_keys files with dozens of entries. We recommend using user@hostname rather than a generic label so the key's origin is auditable at a glance. If you are spinning up infrastructure for a project, a consistent naming scheme for keys and hostnames matters more than most sysadmins admit - services like nicename.me can help when you also need a clean domain to go with the stack.
Set a strong passphrase. On workstations, use ssh-agent to avoid retyping it constantly.
# Generate Ed25519 key pair
ssh-keygen -t ed25519 -C "user@hostname" -f ~/.ssh/id_ed25519
# RSA 4096 fallback for legacy targets
ssh-keygen -t rsa -b 4096 -C "user@hostname" -f ~/.ssh/id_rsa_legacy
# Start ssh-agent and add key
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
# Verify loaded keys
ssh-add -l
Copying the Public Key to the Server
ssh-copy-id handles the authorized_keys setup correctly, including permissions. Do not manually cat the key into the file and then wonder why SSH still prompts for a password - wrong permissions on ~/.ssh or ~/.ssh/authorized_keys are the most common cause.
If ssh-copy-id is unavailable, the manual method below replicates exactly what it does. The .ssh directory must be 700 and authorized_keys must be 600, both owned by the target user.
# Preferred method
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@192.168.1.100
# Manual method if ssh-copy-id is unavailable
cat ~/.ssh/id_ed25519.pub | ssh user@192.168.1.100 \
'mkdir -p ~/.ssh && chmod 700 ~/.ssh && \
cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
# Verify from the server side
ls -la ~/.ssh/
Hardening sshd_config
The default sshd_config on most distributions in 2026 is better than it was five years ago, but still leaves several attack surface items enabled. The changes below are the ones we apply to every server before it faces the internet.
Disabling PasswordAuthentication is the single highest-impact change. Brute-force attacks against SSH password auth are constant background noise on any public IP - disabling it eliminates that entire attack vector.
Set LoginGraceTime to 20 or 30 seconds. The default is 120 seconds, which keeps unauthenticated TCP connections open long enough to be used in resource exhaustion. MaxAuthTries 3 limits credential stuffing attempts per connection.
PermitRootLogin should be no. If you need root access, log in as a regular user and sudo. If operational requirements force direct root SSH (rare but real), use PermitRootLogin prohibit-password so only key-based root login is possible.
AllowUsers or AllowGroups provides an explicit allowlist. If sshd does not see the connecting username in AllowUsers, the connection is rejected before any authentication occurs. We use AllowGroups sshusers and maintain that group in Ansible or whatever config management is in play.
After editing, always validate the config before reloading the daemon. A syntax error in sshd_config with a careless reload can lock you out entirely.
# /etc/ssh/sshd_config - hardened block
Port 22
ListenAddress 0.0.0.0
Protocol 2
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
LoginGraceTime 20
PermitRootLogin no
StrictModes yes
MaxAuthTries 3
MaxSessions 10
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
X11Forwarding no
PrintMotd no
AcceptEnv LANG LC_*
Subsystem sftp /usr/lib/openssh/sftp-server
AllowGroups sshusers
ClientAliveInterval 300
ClientAliveCountMax 2
Validating and Reloading the Daemon
Never reload sshd without testing the config first. Run sshd -t as root - it exits silently on success and prints the error line on failure. Then open a second terminal session and keep it alive while you reload; if the new config breaks something, your existing session stays up.
On systemd systems, reload rather than restart. reload sends SIGHUP, which causes sshd to re-read the config and apply it to new connections while keeping existing sessions alive. restart drops all active sessions.
# Test config syntax
sudo sshd -t
# Extended test output
sudo sshd -T | grep -E 'passwordauth|permitroot|maxauthtries|allowgroups'
# Reload without dropping sessions
sudo systemctl reload ssh # Debian/Ubuntu
sudo systemctl reload sshd # RHEL family
# Confirm the process picked up changes
sudo journalctl -u ssh -n 20 --no-pager
Changing the Default Port
Moving SSH off port 22 is not a security control - it is noise reduction. It eliminates the constant automated scanning that fills auth logs and makes legitimate failed logins easier to spot. On our test server, moving to a non-standard port reduced auth log noise by roughly 98% within 24 hours.
If you change the port, update firewalld or ufw before reloading sshd, and update your SELinux policy on RHEL systems. Forgetting the SELinux step is a classic lockout scenario on RHEL and Rocky.
For client connections to non-standard ports, pass -p or configure the port in ~/.ssh/config.
# In /etc/ssh/sshd_config
Port 2222
# UFW (Ubuntu)
sudo ufw allow 2222/tcp
sudo ufw delete allow 22/tcp
# firewalld (Rocky/RHEL)
sudo firewall-cmd --permanent --add-port=2222/tcp
sudo firewall-cmd --permanent --remove-service=ssh
sudo firewall-cmd --reload
# SELinux - allow new port (RHEL family only)
sudo semanage port -a -t ssh_port_t -p tcp 2222
# Connect from client
ssh -p 2222 user@server.example.com
SSH Client Config File
The ~/.ssh/config file eliminates repetitive flag typing and makes complex multi-hop environments manageable. Each Host block sets parameters for matching hostnames. The Match directive provides conditional logic based on user, host, or local port.
ProxyJump (introduced in OpenSSH 7.3) replaces the older ProxyCommand pattern for bastion-host setups. You can chain multiple jump hosts with a comma-separated list. The ForwardAgent yes directive on the bastion entry allows the agent running on your local machine to authenticate the second hop without copying private keys to the bastion.
ServerAliveInterval and ServerAliveCountMax in the client config mirror the server-side ClientAliveInterval - they keep sessions from dropping on idle connections through NAT or firewalls that expire idle TCP state.
# ~/.ssh/config
Host bastion
HostName bastion.example.com
User ops
Port 2222
IdentityFile ~/.ssh/id_ed25519
ForwardAgent yes
ServerAliveInterval 60
ServerAliveCountMax 3
Host prod-*
User deploy
IdentityFile ~/.ssh/id_ed25519
ProxyJump bastion
ServerAliveInterval 60
ServerAliveCountMax 3
Host prod-web-01
HostName 10.0.1.10
Host prod-db-01
HostName 10.0.1.20
Host *
AddKeysToAgent yes
IdentitiesOnly yes
Port Forwarding and Tunneling
SSH port forwarding is a legitimate operations tool that also carries real risk if sshd is misconfigured on a multi-tenant system. Know the three variants: local, remote, and dynamic.
Local forwarding (-L) binds a port on your local machine and forwards traffic through the SSH connection to a destination reachable from the server. The classic use case is accessing a database on a private subnet through a bastion host without opening the DB port to the internet.
Remote forwarding (-R) exposes a local port through the server, which is useful for giving temporary access to a local dev environment. Disable GatewayPorts in sshd_config if you do not want remote-forwarded ports to be accessible to anyone other than localhost on the server.
Dynamic forwarding (-D) creates a SOCKS5 proxy. Traffic is sent through the SSH connection to the remote server, which then makes the outbound requests. This is exactly what a VPN tunnel does for specific traffic. For persistent VPN-grade privacy on Linux, a purpose-built client like NordVPN's native Linux CLI (https://nordvpn.com/?ref=PLACEHOLDER) gives you kill-switch and split-tunneling controls that an SSH SOCKS proxy cannot. For quick one-off tunneling through a trusted server you already administer, the SSH dynamic forward is sufficient.
The -N flag suppresses remote command execution, and -f forks the process to background. Use -o ExitOnForwardFailure=yes so the SSH process exits rather than silently failing when the local port is already bound.
# Local forward: access remote MySQL locally on port 3307
ssh -N -f -L 3307:db-private.internal:3306 user@bastion.example.com
mysql -h 127.0.0.1 -P 3307 -u dbuser -p
# Remote forward: expose local port 8080 as port 9000 on the server
ssh -N -f -R 9000:localhost:8080 user@server.example.com
# Dynamic SOCKS5 proxy on local port 1080
ssh -N -f -D 1080 user@server.example.com
curl --socks5 127.0.0.1:1080 https://ifconfig.me
# With failure guard
ssh -N -f -o ExitOnForwardFailure=yes -L 3307:db-private.internal:3306 user@bastion.example.com
Fail2ban for SSH Brute Force Mitigation
Even with password authentication disabled, enabling fail2ban makes sense as a defense-in-depth measure. It will ban IPs hammering your port for initial connection attempts, reducing noise further and protecting against scenarios where a misconfiguration temporarily re-enables password auth.
The sshd jail in fail2ban 1.x uses the backend autodetect which picks up systemd journal on modern distros. Override the port in jail.local if you moved SSH off 22.
sudo apt install -y fail2ban # or dnf install fail2ban
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 3
[sshd]
enabled = true
port = 2222
backend = systemd
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
Automating SSH Config Management
Manually maintaining authorized_keys across dozens of servers does not scale. At ten servers it is annoying; at a hundred it is a security liability because revocation becomes unreliable. The standard approaches are Ansible's authorized_key module, HashiCorp Vault SSH secrets engine, or a purpose-built certificate authority.
OpenSSH certificates (not to be confused with TLS certificates) let you sign user public keys with a CA private key. Servers trust the CA rather than individual keys, so adding or revoking access is a CA operation with no per-server file edits. The signed certificate includes an expiry, which is something a static authorized_keys entry cannot express.
For teams running automated pipelines and looking to integrate SSH key management into broader DevOps workflows, platforms like taskbotshub.ai surface tooling for automating repetitive infrastructure tasks including credential rotation at scale.
Below is the minimal CA setup. In production, keep the CA private key on an offline or HSM-backed system.
# On the CA machine - generate CA key
ssh-keygen -t ed25519 -f ~/.ssh/ca_key -C "ssh-ca-2026"
# Sign a user key (valid 8 hours, user principal 'ops')
ssh-keygen -s ~/.ssh/ca_key \
-I "user@hostname-2026-06-23" \
-n ops \
-V +8h \
~/.ssh/id_ed25519.pub
# This produces id_ed25519-cert.pub
ssh-keygen -L -f ~/.ssh/id_ed25519-cert.pub
# On each server - trust the CA
# Add to /etc/ssh/sshd_config:
# TrustedUserCAKeys /etc/ssh/ca_key.pub
# Copy CA public key to server
sudo cp ca_key.pub /etc/ssh/ca_key.pub
echo 'TrustedUserCAKeys /etc/ssh/ca_key.pub' | sudo tee -a /etc/ssh/sshd_config
sudo systemctl reload sshd
# Client connects automatically using the cert
ssh -i ~/.ssh/id_ed25519 -i ~/.ssh/id_ed25519-cert.pub ops@server.example.com
Auditing Active Sessions and Authorized Keys
Knowing who is currently connected and what keys are authorized matters for incident response. who and w give you active session info; ss or netstat shows the TCP connections. For authorized_keys auditing across a fleet, a simple find pipeline gets you started before you have proper tooling.
The sshd -T flag we used earlier for config testing is also useful for confirming what effective config a running daemon is using, including settings that were inherited from Include directives or compiled-in defaults.
# Active SSH sessions
who
w
ss -tnp | grep sshd
# All authorized_keys files on the system
find /home /root -name authorized_keys 2>/dev/null -exec echo "=== {} ==" \; -exec cat {} \;
# Check last logins
lastlog | grep -v Never
journalctl -u sshd --since "24 hours ago" | grep 'Accepted\|Failed' | tail -50
# Effective sshd config
sudo sshd -T 2>/dev/null | grep -E 'passwordauth|pubkeyauth|permitroot|allowgroups|port|maxauthtries'