SSH, fail2ban, security headers, and kernel hardening. No shortcuts.
Overview
With the infrastructure in place — VPS provisioned, Nginx running, TLS configured — the next step is hardening. A server exposed to the internet starts collecting brute-force attempts within minutes of going live. This post covers the full hardening process: SSH configuration, fail2ban, Nginx security headers, and sysctl kernel parameters.
Step 7: SSH Hardening
The default sshd_config on Ubuntu 24.04 is not bad, but it leaves several settings at permissive defaults.
The goal here is to reduce the attack surface to the minimum necessary.
Reviewing the defaults
Before making changes, check whether anything lives in /etc/ssh/sshd_config.d/:
ls -la /etc/ssh/sshd_config.d/
cat /etc/ssh/sshd_config.d/*.conf 2>/dev/null || echo "empty or no files"
On Ubuntu 24.04, cloud-init can drop files here that silently override your main config. In our case — empty. Good.
Changes applied
LoginGraceTime 30
MaxAuthTries 3
MaxSessions 2
MaxStartups 3:50:10
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers <your_username>
PermitRootLogin no
PasswordAuthentication no
PermitEmptyPasswords no
KbdInteractiveAuthentication no
AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
PermitUserEnvironment no
LogLevel VERBOSE
The reasoning:
- LoginGraceTime 30 — default is 2 minutes. That's a long window for a connection to hang during an attack. 30 seconds is plenty for a legitimate login.
- MaxAuthTries 3 — default is 6. Halved. Three wrong attempts and the connection is dropped.
- MaxSessions 2 — single-user server. No reason to allow 10 concurrent sessions.
- MaxStartups 3:50:10 — limits half-open connections. Protects against scanning and DoS. The default
10:30:100is generous to a fault. - ClientAliveInterval / ClientAliveCountMax — without these, idle sessions hang indefinitely. After 10 minutes of silence (300s × 2), the session is terminated.
- AllowUsers <your_username> — explicit whitelist. If another user is ever added to the system by mistake, they cannot log in via SSH without being explicitly added here.
- PermitRootLogin no — root has no business logging in directly over SSH. Log in as a regular user and escalate with sudo if needed. Disabling this closes the most obvious brute-force target.
- PasswordAuthentication no — passwords are guessable, leaked, and reused. Keys are not. This is non-negotiable on any internet-facing server.
- PermitEmptyPasswords no — belt and suspenders. Should never be an issue if PasswordAuthentication is off, but there's no reason to leave it unset.
- KbdInteractiveAuthentication no — disables the PAM keyboard-interactive method. On some configurations this can be used to sneak password prompts back in even with PasswordAuthentication off. Close it explicitly.
- AllowAgentForwarding no — SSH agent forwarding lets a remote server use your local SSH keys. Useful in some workflows, a liability on a server you don't fully control. Not needed here.
- AllowTcpForwarding no — prevents using SSH as a tunnel to proxy traffic through the server. No legitimate use case on this box.
- X11Forwarding no — headless server, no display. This has no reason to be on.
- PermitUserEnvironment no — blocks users from injecting environment variables via
~/.ssh/environmentorAcceptEnv. A minor vector, but trivial to close. - LogLevel VERBOSE — the default
INFOdoes not log the fingerprint of the key used to authenticate. WithVERBOSE, every login records which key was used. Without this, forensic analysis of a compromise leaves a gap: you know someone logged in, but not with which key.
Applying changes safely
Always validate config before restarting:
sudo sshd -t && sudo systemctl restart ssh.socket
sshd -t exits silently on success — that's expected Unix behavior. Keep the existing session open and verify login from a new window before closing anything.
Step 8: fail2ban
sshd_config limits what happens per connection. fail2ban handles persistent attackers across connections —
it watches auth logs and bans IPs that exceed a threshold.
sudo apt install fail2ban -y
fail2ban ships with jail.conf as the default config. Never edit it directly — it gets overwritten on updates.
The correct approach is a local override:
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
Then configure the sshd jail in jail.local:
[DEFAULT]
bantime = 3600
[sshd]
enabled = true
port = ssh
filter = sshd
backend = systemd
logpath = /var/log/auth.log
maxretry = 3
findtime = 600
bantime = 86400
On Ubuntu 24.04 with systemd, backend = systemd is the right choice — it reads directly from the journal
rather than a log file. Three failed attempts within 10 minutes triggers a 1-hour ban.
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
Step 9: Nginx Security Headers
A server that scores poorly on security headers is advertising its configuration to anyone who looks. These headers are trivial to add and cover a meaningful surface area.
In /etc/nginx/sites-available/svir0x.dev, inside the 443 server block:
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; frame-ancestors 'none';" always;
A few notes:
X-XSS-Protection "0"— the modern recommendation is to disable this header entirely. It was implemented inconsistently across browsers and could introduce vulnerabilities of its own. CSP handles XSS protection properly.- The CSP policy is maximally restrictive: everything must originate from the same domain, no exceptions. This works for a pure HTML/CSS site with no external dependencies. If external fonts or scripts are ever added, the policy needs updating.
.devis already HSTS preloaded at the browser level, but the explicit header is still correct practice.
Verify config and reload:
sudo nginx -t && sudo systemctl reload nginx
Result: A+ on securityheaders.com.
Step 10: sysctl Kernel Hardening
The Linux kernel exposes a set of runtime parameters through /proc/sys/.
The sysctl interface lets you tune these without recompiling anything.
The right approach is a drop-in file under /etc/sysctl.d/ rather than editing /etc/sysctl.conf directly.
sudo nano /etc/sysctl.d/99-hardening.conf
# IP Spoofing protection
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Ignore ICMP broadcast requests
net.ipv4.icmp_echo_ignore_broadcasts = 1
# Disable source packet routing
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
# Ignore send redirects
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
# Block SYN attacks
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 2048
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 5
# Log martians
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
# Ignore ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
# IPv6 — enabled (in use)
net.ipv6.conf.all.disable_ipv6 = 0
# Protect against time-wait assassination
net.ipv4.tcp_rfc1337 = 1
# Disable kernel pointer leaks
kernel.kptr_restrict = 2
# Restrict dmesg
kernel.dmesg_restrict = 1
# Restrict ptrace
kernel.yama.ptrace_scope = 1
# Ignore all ICMP echo requests (optional — uncomment if stealth is desired)
# net.ipv4.icmp_echo_ignore_all = 1
Apply immediately without rebooting:
sudo sysctl -p /etc/sysctl.d/99-hardening.conf
The parameters persist across reboots because they live in /etc/sysctl.d/.