Danila (Dayfing)
Back to writing
3,152 words17 min

Linux VPS hardening checklist: SSH, firewall, updates, monitoring

In the first hour on a fresh Ubuntu or Debian VPS, close the doors that automated scanners try within minutes of an IP address going live. Patch the system, log in as a non-root sudo user with an Ed25519 key, disable password and root logins in an SSH drop-in, and enable a default-deny firewall that exposes only SSH and the web ports, ideally only to your CDN. Then turn on unattended security upgrades with planned reboots, add fail2ban or CrowdSec, sandbox the application under systemd, and set up backups and alerts before DNS points at the server.

The commands use Ubuntu 24.04 LTS as the reference and note where Debian 12 and 13 differ. Keep the provider's web console open while you work. It is the way back in if an SSH or firewall change locks you out.

Patch the base system and create a sudo user

Many providers hand over a root login with a password, so replace both first. Apply pending updates, then create a personal account with sudo rights:

apt update && apt full-upgrade -y
adduser deploy
usermod -aG sudo deploy

Ubuntu cloud images already have an ubuntu account with sudo rights. On a minimal Debian install with a root password, sudo may be missing, so run apt install sudo first. Give each person a named account so the audit trail maps to a human. UTC keeps log correlation simple across servers and CDN dashboards: timedatectl set-timezone Etc/UTC.

Where Debian differs from Ubuntu 24.04

Area Ubuntu 24.04 LTS Debian 12 and 13
SSH listener ssh.socket starts the daemon on demand usually ssh.service; check with systemctl status
Firewall ufw installed but inactive nothing enabled; nftables is the default framework
Automatic updates unattended-upgrades installed and enabled may be missing; install and enable it
Authentication log /var/log/auth.log and the journal journal only; rsyslog is not installed by default since Debian 12
sudo installed may be missing on minimal installs
OpenSSH 9.6 9.2 in Debian 12, 10.0 in Debian 13

SSH with Ed25519 keys and a hardening drop-in

Create and install the key

Generate the key on your workstation, never on the server. Ed25519 keys are short, fast, and supported by every current OpenSSH release. Protect the private key with a passphrase and let ssh-agent hold it. With a FIDO2 security key, ssh-keygen -t ed25519-sk binds the key to hardware.

ssh-keygen -t ed25519 -C "deploy@laptop-2026"
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]

ssh-copy-id needs one password login. If root already accepts your key and passwords are off, copy the key file on the server instead:

sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
sudo install -m 600 -o deploy -g deploy /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys

The modes matter: with StrictModes on, the default, sshd ignores key files that other users can write.

Write the drop-in

Ubuntu's /etc/ssh/sshd_config starts with Include /etc/ssh/sshd_config.d/*.conf, and the sshd_config manual states that the first obtained value of each keyword is used. Drop-ins are read in lexical order and win over the rest of the main file. Cloud images often ship a file such as 50-cloud-init.conf or 60-cloudimg-settings.conf that sets PasswordAuthentication, so give yours a low prefix:

# /etc/ssh/sshd_config.d/00-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers deploy
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no

KbdInteractiveAuthentication no closes the PAM keyboard-interactive path, which can still ask for a password when PasswordAuthentication is off. AllowUsers limits logins to named accounts. Ubuntu's main file sets X11Forwarding yes, and the drop-in wins because it is read first. If nobody needs forwarding, add AllowAgentForwarding no and AllowTcpForwarding no.

Validate, print the effective values, and apply:

sudo sshd -t
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|kbdinteractive|authenticationmethods|allowusers'
sudo systemctl restart ssh

sshd -t catches syntax errors, and sshd -T shows the configuration that actually applies, including whether a cloud-init file won. Restarting ssh keeps established sessions. On Ubuntu 24.04 the listening socket belongs to ssh.socket, so changing Port or ListenAddress also needs sudo systemctl daemon-reload and sudo systemctl restart ssh.socket, after the firewall allows the new port. Ubuntu's OpenSSH server guide covers the other options.

Test in a second session before closing the first

Keep the current session open. From a new terminal, log in as deploy with the key and run sudo -v. Then confirm that the closed paths fail:

ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password [email protected]
ssh [email protected]

Both attempts must end with Permission denied (publickey). Close the original session only after that. Moving SSH to another port reduces log noise, but it is not a security control.

A default-deny firewall with ufw or nftables

The host firewall is the second layer after the provider's network firewall, if one exists. Drop everything inbound except SSH and the web ports, allow outbound traffic, and cover IPv6 as well as IPv4.

Ubuntu: ufw

Add the SSH rule before enabling the firewall, or ufw enable cuts your session:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit OpenSSH
sudo ufw allow proto tcp from any to any port 80,443 comment 'web'
sudo ufw enable
sudo ufw status verbose

According to the ufw manual, a limit rule denies an address that opens 6 or more connections within 30 seconds. That slows scanners but can trip automation that opens many parallel SSH connections, where a plain allow fits better. With a fixed admin address or a VPN, allow SSH only from it: sudo ufw allow from 198.51.100.7 to any port 22 proto tcp. Rules cover IPv6 while IPV6=yes stays set in /etc/default/ufw, the default.

The Docker documentation warns that ports published by Docker bypass ufw and firewalld. Publish containers on loopback, for example -p 127.0.0.1:3000:3000, and let the reverse proxy face the internet.

Debian: nftables

Debian uses nftables as its firewall framework. This /etc/nftables.conf applies the same policy and prepares named sets for the CDN ranges in the next section:

#!/usr/sbin/nft -f
flush ruleset

table inet filter {
  set cloudflare_v4 {
    type ipv4_addr
    flags interval
  }

  set cloudflare_v6 {
    type ipv6_addr
    flags interval
  }

  chain input {
    type filter hook input priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop
    iif "lo" accept
    meta l4proto { icmp, ipv6-icmp } accept
    tcp dport 22 accept
    ip saddr @cloudflare_v4 tcp dport { 80, 443 } accept
    ip6 saddr @cloudflare_v6 tcp dport { 80, 443 } accept
  }

  chain forward {
    type filter hook forward priority filter; policy drop;
  }
}

Check it with sudo nft -c -f /etc/nftables.conf, then run sudo systemctl enable --now nftables. Keep ICMPv6 open, because IPv6 neighbor discovery and path MTU discovery depend on it. flush ruleset also removes tables created by fail2ban or Docker, so restart them after a reload. Use one front end per host.

Let only the CDN reach the web ports

Behind Cloudflare or another CDN, the origin should not answer anyone else on ports 80 and 443. Otherwise anyone who finds the origin address, through old DNS records, a mail server on the same IP, or internet-wide scans, can bypass the CDN's WAF, rate limits, and cache. Cloudflare publishes its ranges at https://www.cloudflare.com/ips-v4 and https://www.cloudflare.com/ips-v6, and its IP address documentation says new ranges are added to the list before they go into production.

With ufw, add one rule per range. The files have no trailing newline, so the loop must handle the last line explicitly or it silently skips a range:

#!/usr/bin/env bash
set -euo pipefail
for list in ips-v4 ips-v6; do
  curl -fsS "https://www.cloudflare.com/$list" |
    while read -r cidr || [ -n "$cidr" ]; do
      ufw allow proto tcp from "$cidr" to any port 80,443 comment 'cloudflare'
    done
done

Then remove the open rule with sudo ufw delete allow proto tcp from any to any port 80,443. With nftables, refresh both sets in one transaction, because nft -f applies the whole batch or nothing. If a download fails, set -e stops the script and the old sets stay:

#!/usr/bin/env bash
set -euo pipefail
v4=$(curl -fsS https://www.cloudflare.com/ips-v4 | paste -sd, -)
v6=$(curl -fsS https://www.cloudflare.com/ips-v6 | paste -sd, -)
nft -f - <<EOF
flush set inet filter cloudflare_v4
flush set inet filter cloudflare_v6
add element inet filter cloudflare_v4 { $v4 }
add element inet filter cloudflare_v6 { $v6 }
EOF

Run it at boot after nftables.service and weekly from a systemd timer. Nginx then needs set_real_ip_from for the same ranges and real_ip_header CF-Connecting-IP;, or logs and rate limits see Cloudflare addresses instead of visitors. The Nginx and Cloudflare caching guide covers the web server side.

An IP allowlist proves that a connection comes from Cloudflare's network, not that it belongs to your zone. Cloudflare's origin protection guide lists allowlisting as vulnerable to IP spoofing and describes stronger options: Authenticated Origin Pulls, which need the Full or Full (strict) encryption mode, and Cloudflare Tunnel, which needs no inbound web ports at all.

Unattended upgrades and kernel reboots

Ubuntu Server installs unattended-upgrades and enables security updates by default, as the automatic updates guide describes. Verify it:

cat /etc/apt/apt.conf.d/20auto-upgrades
sudo unattended-upgrade --dry-run -v

The file should contain APT::Periodic::Update-Package-Lists "1"; and APT::Periodic::Unattended-Upgrade "1";. On Debian, run sudo apt install unattended-upgrades and sudo dpkg-reconfigure -plow unattended-upgrades.

Kernel fixes take effect only after a reboot. On Ubuntu 24.04, needrestart restarts services that use updated libraries, but it cannot replace the running kernel. When a package needs a reboot, /var/run/reboot-required appears. Put local settings in a file that sorts after the packaged 50unattended-upgrades:

// /etc/apt/apt.conf.d/52unattended-upgrades-local
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:30";

Pick a low-traffic time and make sure the application returns on boot without manual steps. Logs are in /var/log/unattended-upgrades/. Canonical Livepatch, free for personal use on up to five machines, patches critical and high kernel vulnerabilities in memory, but Canonical states that it does not replace rebooting.

fail2ban or CrowdSec

With password login disabled, brute force against SSH cannot succeed. A banning tool is a second layer that cuts log noise, saves CPU on handshakes, and slows scanners. Pick one.

fail2ban is packaged in both distributions and needs no account. On Ubuntu 24.04 the package enables the sshd jail with the systemd journal backend and nftables actions, and the journal backend also suits Debian, which has no auth.log. Install it with sudo apt install fail2ban, then tune the jail in a local file instead of jail.conf:

# /etc/fail2ban/jail.d/sshd.local
[sshd]
enabled = true
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
bantime.increment = true

Apply it with sudo systemctl restart fail2ban and inspect the jail with sudo fail2ban-client status sshd.

CrowdSec parses the same logs, shares signals with a community blocklist, and applies decisions through a separate remediation component. Ubuntu 24.04 ships version 1.4.6, and the CrowdSec installation guide asks for a newer version from the vendor repository. Read the setup script before you run it:

curl -s https://install.crowdsec.net | sudo sh
sudo apt install crowdsec crowdsec-firewall-bouncer-nftables
sudo cscli collections list
sudo cscli decisions list

Behind a CDN, web requests arrive from CDN addresses. A host firewall ban for an HTTP attacker either blocks a Cloudflare edge node or targets an address that never connects directly. Keep host bans for SSH and use the CDN's WAF and rate limiting rules for HTTP.

Sandbox the application with systemd

Run the backend as its own system user under a systemd unit, not in a shell or under nohup, and let systemd remove what the process never needs. Create the user with sudo useradd --system --no-create-home --shell /usr/sbin/nologin webapp. This unit runs a Node.js service on localhost behind Nginx:

# /etc/systemd/system/webapp.service
[Unit]
Description=Example web application
After=network-online.target
Wants=network-online.target

[Service]
User=webapp
Group=webapp
WorkingDirectory=/opt/webapp
ExecStart=/usr/bin/node /opt/webapp/server.js
Environment=HOST=127.0.0.1 PORT=3000
LoadCredential=db_password:/etc/webapp/db_password
Restart=on-failure
StateDirectory=webapp
UMask=0077

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
CapabilityBoundingSet=
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

[Install]
WantedBy=multi-user.target

NoNewPrivileges=yes stops the process and its children from gaining privileges through setuid binaries or file capabilities. ProtectSystem=strict mounts the file system read-only for the service, except /dev, /proc, and /sys, so the only writable location is /var/lib/webapp from StateDirectory=. Add ReadWritePaths= for anything else. ProtectHome=yes hides /home, /root, and /run/user, and PrivateTmp=yes gives the service its own /tmp and /var/tmp. An empty CapabilityBoundingSet= drops every capability, and @system-service is the allow list that the systemd.exec manual recommends as a starting point.

MemoryDenyWriteExecute=yes is left out on purpose: the manual says it is incompatible with JIT engines, and V8 in Node.js generates code at run time. Add it to Go, Rust, or C services, and test.

On Ubuntu 24.04 with systemd 255, systemd-analyze security --offline=true rated this file at 1.5, against 9.0 for the same unit with only User= set. Start it and check:

sudo systemctl daemon-reload
sudo systemctl enable --now webapp
systemd-analyze security webapp.service
journalctl -u webapp -b -n 50 --no-pager

If the service fails, the journal usually names the blocked path or system call. Relax one option at a time instead of deleting the block. Packaged services such as Nginx start as root and need specific capabilities, so harden them through sudo systemctl edit nginx one option at a time.

Time sync, journald, and log retention

TLS validation, TOTP codes, backup schedules, and log correlation all assume a correct clock. Ubuntu 24.04 and Debian 12 both default to systemd-timesyncd. Run timedatectl status and look for System clock synchronized: yes and NTP service: active. If no NTP service runs, install systemd-timesyncd or chrony.

With the default Storage=auto, the journal stays on disk only when /var/log/journal exists, and it may use up to 10 percent of the file system, capped at 4 GB. Set explicit limits so a noisy service cannot fill a small disk and retention matches your privacy policy:

# /etc/systemd/journald.conf.d/50-retention.conf
[Journal]
Storage=persistent
SystemMaxUse=1G
MaxRetentionSec=30day

Apply it with sudo systemctl restart systemd-journald and check journalctl --disk-usage. Nginx and application logs in /var/log are rotated by logrotate, so align /etc/logrotate.d/ with the same period. The Debian 12 release notes confirm that rsyslog is no longer installed by default, so on Debian the journal is the only system log. Access logs contain IP addresses, which can be personal data under the GDPR, so shorter retention is often correct.

Secrets and file permissions

Secrets on a VPS usually leak through ordinary paths: a .env file every user can read, a key in Git history, or an environment variable printed in a crash report. The systemd manual states that environment variables are not suitable for secrets, because they are exposed to unprivileged clients over D-Bus and inherited by child processes. Use LoadCredential=, as in the unit above. The source file stays readable by root only, and the service gets a read-only copy under $CREDENTIALS_DIRECTORY:

sudo install -d -m 0700 -o root -g root /etc/webapp
sudo install -m 0600 -o root -g root /dev/null /etc/webapp/db_password
sudoedit /etc/webapp/db_password

systemd-creds encrypt with LoadCredentialEncrypted= also keeps the file encrypted at rest with a host key or the TPM. Keep application code owned by root or a deploy user, not by the service user, so a compromised process cannot rewrite itself. After installing third-party software, look for world-writable files and unexpected setuid binaries:

sudo find / -xdev -type f -perm -0002 -print
sudo find / -xdev -type f -perm -4000 -print

Give a CI deploy key its own line in authorized_keys with the restrict option and, where possible, a from= address list. Coding agents that run commands on the server need the same discipline: a separate user, no production secrets, and approval for destructive commands. The agentic coding guide compares the permission controls of current coding CLIs.

Backups and monitoring hooks

Hardening lowers the chance of an incident, while backups and alerts decide how long one lasts. Provider snapshots help with fast rollback, but they live in the same account as the server, so they are not an independent copy. Keep an encrypted, versioned copy outside the provider and test a restore. The 3-2-1 backup guide with restic covers repository layout, retention, and restore drills.

A single VPS needs a handful of signals: an external HTTP check through the CDN, a check that the origin refuses direct connections, disk usage, certificate expiry, failed systemd units, and a heartbeat from every scheduled job, so a backup that silently stops raises an alert. systemd can call a notifier whenever a unit fails:

# /etc/systemd/system/[email protected]
[Unit]
Description=Alert on failure of %i

[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify-failure %i

Add OnFailure=notify-failure@%n.service to the [Unit] section of the application and backup units. The script can post the unit name and recent journal lines to a chat or paging webhook. Metrics exporters, such as the Prometheus node exporter, belong on localhost or a private network. If the server runs AI agents or MCP tools with shell access, give them their own user and sandbox, and treat their inputs as untrusted, as the prompt injection and MCP security guide explains.

The first-hour checklist

  1. Apply updates, create a named sudo user, and keep the provider console open.
  2. Install an Ed25519 key and add 00-hardening.conf with root and password logins disabled.
  3. Run sshd -t and sshd -T, restart ssh, and test a new session before closing the old one.
  4. Enable a default-deny firewall for IPv4 and IPv6 that allows SSH, 80, and 443.
  5. Limit the web ports to Cloudflare ranges or use Authenticated Origin Pulls or Tunnel, and restore visitor IPs in Nginx.
  6. Confirm unattended security upgrades and schedule automatic reboots.
  7. Enable fail2ban or CrowdSec for SSH and use the CDN's WAF for HTTP.
  8. Run the application as its own user in a sandboxed systemd unit and check systemd-analyze security.
  9. Confirm time sync and set journal size and retention limits.
  10. Move secrets into root-only credential files and check file permissions.
  11. Set up off-provider backups, test a restore, and add failure alerts and heartbeats.
  12. Reboot once on purpose and confirm that every service returns without manual steps.

More