Skip to content

Linux VPS Hardening: A Complete Hands-On Guide

Step by step hardening for an internet-facing Ubuntu, Debian or RHEL server: SSH, firewalls, kernel settings, sandboxing, auditing, integrity monitoring, backups and verification.

ABYSSS Security Team21 min read

A virtual server wrapped in rings for SSH keys, kernel, firewall and audit controls, with attacks stopped at the edge.

A fresh virtual private server with a public IP address is scanned within minutes of boot. Automated bots try default credentials over SSH, probe for exposed databases and admin panels, and fire exploits for every popular web framework. None of this is targeted, which is exactly why it works: the attacker only needs one server that skipped the basics. This guide walks through hardening a Linux VPS from first login to ongoing verification, with the commands to do each step and the tools to check your work.

First login: patch, inventory and set the clock

Before hardening anything, bring the system up to date and record what it looks like. Provider images are often weeks old, and the inventory you take now is the baseline you will compare against during an incident.

Patch and baseline · Ubuntu and Debian
sudo apt update && sudo apt full-upgrade -y
sudo apt autoremove --purge -y
[ -f /var/run/reboot-required ] && sudo reboot

# RHEL, Rocky, Alma
sudo dnf upgrade --refresh -y
sudo needs-restarting -r || sudo reboot
Identity, time and a first look at exposure
sudo hostnamectl set-hostname web-01.example.com
sudo timedatectl set-timezone UTC
timedatectl status            # "System clock synchronized: yes"

sudo ss -tulpn                # every listening socket and its process
systemctl list-units --type=service --state=running
last -n 20; sudo lastb -n 20  # recent and failed logins
cat /etc/os-release; uname -r

Use UTC on servers. Logs from many machines in different regions only line up in an investigation if they share one clock, and correct time also matters for TLS, Kerberos and TOTP codes. Ubuntu and Debian use systemd-timesyncd by default; chrony is a better choice for servers that need tighter accuracy and is the default on RHEL.

Users, sudo and password policy

Never work as root. Create a named administrator for each person, give them sudo through a group, and lock the root password so the only way to root is through an audited sudo call.

Create an admin user and lock root
sudo adduser alice                       # Debian and Ubuntu
sudo usermod -aG sudo alice              # use "wheel" on RHEL
sudo groupadd sshusers && sudo usermod -aG sshusers alice

sudo passwd -l root                      # lock the root password
sudo chage -l alice                      # check password ageing

Tighten sudo with a drop-in file rather than editing the main sudoers file. Always edit through visudo, which refuses to save a file with syntax errors that would otherwise lock every admin out of sudo.

/etc/sudoers.d/10-hardening · edit with visudo -f
Defaults use_pty
Defaults logfile="/var/log/sudo.log"
Defaults log_input, log_output
Defaults timestamp_timeout=5
Defaults passwd_tries=3
Defaults !visiblepw

The use_pty option stops a backgrounded process from injecting keystrokes into your terminal after sudo exits. Input and output logging records full sudo sessions under /var/log/sudo-io, which can be replayed with sudoreplay during an investigation. Even with key-only SSH, local passwords still protect sudo, so set a sensible policy.

/etc/security/pwquality.conf and /etc/login.defs
# /etc/security/pwquality.conf   (apt install libpam-pwquality)
minlen = 14
minclass = 3
maxrepeat = 3
dictcheck = 1
enforce_for_root

# /etc/login.defs
PASS_MAX_DAYS   365
PASS_MIN_DAYS   1
PASS_WARN_AGE   14
UMASK           027

Find and remove anything that should not be able to log in: unused accounts, accounts with empty passwords, and any account other than root with UID 0.

Account review
awk -F: '$3 == 0 {print $1}' /etc/passwd          # only "root" expected
sudo awk -F: '$2 == "" {print $1}' /etc/shadow      # empty passwords
awk -F: '$7 !~ /(nologin|false)$/ {print $1, $7}' /etc/passwd
sudo usermod -s /usr/sbin/nologin olduser && sudo usermod -L olduser

SSH: keys only, no root, modern crypto

SSH is the front door of almost every VPS, and password authentication is what the bots are hammering. Switch to key-based authentication, ideally with a hardware-backed key, then turn passwords off entirely.

On your workstation: create a key and install it
ssh-keygen -t ed25519 -a 100 -C "alice@laptop"
# or, with a FIDO2 security key such as a YubiKey:
ssh-keygen -t ed25519-sk -O resident -O verify-required -C "alice@yubikey"

ssh-copy-id -i ~/.ssh/id_ed25519.pub alice@203.0.113.10
ssh alice@203.0.113.10        # confirm key login works BEFORE disabling passwords

Put your settings in a drop-in file. On Ubuntu and Debian, sshd_config includes /etc/ssh/sshd_config.d/*.conf at the top, and sshd keeps the first value it reads for each option, so a file named 10-hardening.conf wins over the 50-cloud-init.conf that many cloud images ship with PasswordAuthentication turned on.

/etc/ssh/sshd_config.d/10-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
PermitEmptyPasswords no
AllowGroups sshusers

MaxAuthTries 3
MaxSessions 4
LoginGraceTime 20
ClientAliveInterval 300
ClientAliveCountMax 2

X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
PermitUserEnvironment no
LogLevel VERBOSE

KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
HostKeyAlgorithms ssh-ed25519,sk-ssh-ed25519@openssh.com,rsa-sha2-512,rsa-sha2-256

LogLevel VERBOSE records the fingerprint of the key used for each login, which tells you which key, and therefore which person or system, opened a session. If you need TCP forwarding for a bastion or database tunnel, enable it for that group only with a Match block rather than globally.

Validate, then reload without dropping your session
sudo sshd -t                                  # syntax check, silent on success
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauth|kbdinteractive|allowgroups'
sudo systemctl reload ssh                     # "sshd" on RHEL

# Keep this session open. From a SECOND terminal:
ssh alice@203.0.113.10                        # must succeed
ssh -o PubkeyAuthentication=no alice@203.0.113.10   # must be refused

Remove weak host keys and short Diffie-Hellman groups. Clients will warn that the host key changed if you regenerate keys, so do this on day one and publish the new fingerprints to your team.

Host keys and moduli
sudo rm -f /etc/ssh/ssh_host_*
sudo ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
sudo ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ""

sudo awk '$5 >= 3071' /etc/ssh/moduli | sudo tee /etc/ssh/moduli.safe >/dev/null
sudo mv /etc/ssh/moduli.safe /etc/ssh/moduli
sudo systemctl restart ssh

for k in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$k"; done

The strongest option is to remove SSH from the public internet altogether. Put administrators on a WireGuard or Tailscale network, bind sshd to that interface with ListenAddress, and allow port 22 only from the VPN range. Brute-force traffic then never reaches the daemon. Verify the final configuration from your workstation with ssh-audit, which grades key exchange, ciphers, MACs and host keys and flags anything weak.

Audit the SSH configuration from outside
pipx install ssh-audit
ssh-audit 203.0.113.10
ssh-audit -p 2222 203.0.113.10      # if you changed the port

Firewall: deny by default

Allow only the ports the server exists to serve, deny everything else inbound, and make sure the rules cover IPv6 as well as IPv4. Use two layers where you can: the provider cloud firewall or security group stops traffic before it reaches the VM, and the host firewall protects you if the cloud rule is ever misconfigured.

UFW on Ubuntu and Debian
sudo apt install ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit 22/tcp comment 'SSH, rate limited'
sudo ufw allow 80,443/tcp comment 'Web'
# admin access from one network only:
# sudo ufw allow from 198.51.100.0/24 to any port 22 proto tcp
grep IPV6 /etc/default/ufw                # must be IPV6=yes
sudo ufw enable
sudo ufw status verbose numbered

The limit rule blocks an address that opens six or more connections within 30 seconds, which slows brute force without extra software. If you prefer to manage rules directly, nftables is the modern kernel firewall on every current distribution, and a single inet table covers IPv4 and IPv6 together.

/etc/nftables.conf · then: sudo nft -c -f /etc/nftables.conf && sudo systemctl enable --now nftables
#!/usr/sbin/nft -f
flush ruleset

table inet filter {
  set ssh_ratelimit {
    type ipv4_addr
    flags dynamic, timeout
    timeout 1m
  }

  chain input {
    type filter hook input priority 0; policy drop;

    ct state established,related accept
    ct state invalid drop
    iif "lo" accept

    ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } limit rate 10/second accept
    ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept

    tcp dport 22 ct state new add @ssh_ratelimit { ip saddr limit rate over 10/minute } drop
    tcp dport 22 accept
    tcp dport { 80, 443 } accept

    limit rate 5/minute log prefix "nft-drop: " level info
  }

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

  chain output {
    type filter hook output priority 0; policy accept;
  }
}

Do not drop ICMPv6 neighbor discovery or packet-too-big messages; IPv6 breaks without them. On RHEL family systems, firewalld is the supported front end. Remove services you do not need from the default zone, since cloud images often allow cockpit and dhcpv6-client out of the box.

firewalld on RHEL, Rocky and Alma
sudo firewall-cmd --get-active-zones
sudo firewall-cmd --permanent --remove-service=cockpit
sudo firewall-cmd --permanent --remove-service=dhcpv6-client
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=198.51.100.0/24 service name=ssh accept'
sudo firewall-cmd --permanent --remove-service=ssh     # after the rich rule is in place
sudo firewall-cmd --reload && sudo firewall-cmd --list-all

Egress filtering is worth the effort on servers with a narrow job. A web server rarely needs to open outbound connections on arbitrary ports, and blocking them stops many reverse shells, crypto miners and data exfiltration tools. Start by logging outbound traffic for a week, then allow only DNS, NTP, package mirrors and the APIs the application really calls.

Brute-force protection: Fail2ban or CrowdSec

Fail2ban watches logs for repeated failures and bans the source address in the firewall. With key-only SSH the bans matter less for security, but they cut log volume and protect services that still take passwords, such as web logins and mail.

/etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
bantime.maxtime   = 1w
backend   = systemd
banaction = nftables-multiport
ignoreip  = 127.0.0.1/8 ::1 198.51.100.0/24

[sshd]
enabled = true
mode    = aggressive

[nginx-http-auth]
enabled = true
Install and operate Fail2ban
sudo apt install fail2ban         # RHEL: sudo dnf install epel-release fail2ban
sudo systemctl enable --now fail2ban
sudo fail2ban-client status
sudo fail2ban-client status sshd
sudo fail2ban-client set sshd unbanip 192.0.2.44

CrowdSec is a modern alternative. It parses logs with community scenarios, enforces decisions through bouncers in the firewall or reverse proxy, and shares anonymized attack signals so you can block addresses that attacked other members before they reach you. Install the engine from its package repository and add the nftables firewall bouncer.

CrowdSec engine and firewall bouncer
curl -s https://install.crowdsec.net | sudo sh      # adds the repository; read it first
sudo apt install crowdsec crowdsec-firewall-bouncer-nftables
sudo cscli collections install crowdsecurity/linux crowdsecurity/sshd crowdsecurity/nginx
sudo cscli metrics
sudo cscli decisions list
sudo cscli alerts list

Automatic security updates and reboots

Most compromised servers are compromised through vulnerabilities that already had a patch. Install security updates automatically, and schedule reboots so new kernels and libraries are actually running.

unattended-upgrades on Ubuntu and Debian
sudo apt install unattended-upgrades apt-listchanges needrestart
sudo dpkg-reconfigure -plow unattended-upgrades

# /etc/apt/apt.conf.d/52unattended-upgrades-local
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:30";
Unattended-Upgrade::Mail "ops@example.com";
Unattended-Upgrade::MailReport "on-change";

sudo unattended-upgrade --dry-run --debug
systemctl list-timers 'apt-daily*'
dnf-automatic on RHEL 9, Rocky and Alma
sudo dnf install dnf-automatic

# /etc/dnf/automatic.conf
[commands]
upgrade_type = security
apply_updates = yes
reboot = when-needed

sudo systemctl enable --now dnf-automatic.timer

needrestart tells you which services still run old library versions after an update and can restart them automatically. For servers that cannot reboot often, Ubuntu Pro Livepatch (free for a small number of machines) and the equivalent kpatch service on RHEL apply critical kernel fixes in memory. Livepatching reduces the urgency of reboots; it does not remove the need for them.

Kernel and network stack hardening

Kernel parameters set through sysctl close off spoofing, redirect and information-leak tricks, and make local privilege escalation harder. Put them in their own file so package updates never overwrite them.

/etc/sysctl.d/99-hardening.conf
# Network: anti-spoofing, no redirects, no source routing
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_rfc1337 = 1

# Kernel: hide pointers and logs, restrict debugging interfaces
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
kernel.yama.ptrace_scope = 2
kernel.kexec_load_disabled = 1
kernel.sysrq = 0
kernel.perf_event_paranoid = 3
kernel.randomize_va_space = 2

# File system: block classic /tmp race and link attacks, no setuid core dumps
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
fs.suid_dumpable = 0
Apply and verify
sudo sysctl --system
sysctl kernel.kptr_restrict net.ipv4.conf.all.rp_filter kernel.yama.ptrace_scope
  • Leave net.ipv4.ip_forward at 0 unless the server routes traffic. Docker, Kubernetes and VPN gateways turn it on, and that is expected for them.
  • Do not set net.ipv6.conf.all.accept_ra to 0 blindly. Several providers configure IPv6 through router advertisements, and the server loses its IPv6 address after the next reboot.
  • kernel.yama.ptrace_scope = 2 stops normal users from attaching debuggers to processes. Use 1 instead if developers debug on the box, and never 3 unless you are sure you will not need it before the next reboot, because 3 cannot be lowered at runtime.
  • kernel.kexec_load_disabled is one-way until reboot, which is the point: an attacker with root cannot load a replacement kernel.

Block kernel modules for file systems and network protocols the server will never use. Several local privilege escalation bugs of recent years lived in rarely used modules that load automatically on demand. Using install with /bin/false stops loading even when something asks for the module explicitly, while blacklist only stops automatic loading.

/etc/modprobe.d/hardening.conf
install cramfs /bin/false
install freevxfs /bin/false
install hfs /bin/false
install hfsplus /bin/false
install jffs2 /bin/false
install udf /bin/false
install usb-storage /bin/false
install dccp /bin/false
install sctp /bin/false
install rds /bin/false
install tipc /bin/false
install firewire-core /bin/false

Mount shared temporary directories so nothing placed there can run as setuid or act as a device node. Adding noexec to /tmp also defeats many commodity droppers, but test first: some installers and package scripts execute from /tmp. /dev/shm is safer to lock down because few legitimate programs execute from it.

/etc/fstab additions and core dumps
tmpfs  /dev/shm  tmpfs  defaults,nodev,nosuid,noexec  0 0
tmpfs  /tmp      tmpfs  defaults,nodev,nosuid,size=1G  0 0

sudo systemctl daemon-reload && sudo mount -o remount /dev/shm
findmnt /dev/shm /tmp

# Disable core dumps, which can contain keys and passwords
echo '* hard core 0' | sudo tee /etc/security/limits.d/10-nocore.conf
sudo mkdir -p /etc/systemd/coredump.conf.d
printf '[Coredump]\nStorage=none\nProcessSizeMax=0\n' | sudo tee /etc/systemd/coredump.conf.d/disable.conf

Shrink the attack surface

Every listening service is something to patch and something to exploit. Go back to the ss output from your first login and justify every line. Anything that only the server itself needs, such as a database or cache, should listen on 127.0.0.1 or a Unix socket, never on 0.0.0.0.

Remove and disable what you do not need
sudo ss -tulpn
systemctl list-unit-files --type=service --state=enabled
sudo systemctl disable --now avahi-daemon cups rpcbind 2>/dev/null
sudo apt purge -y telnet rsh-client talk xinetd 2>/dev/null

# Bind local services to loopback
#   PostgreSQL  postgresql.conf   listen_addresses = 'localhost'
#   MySQL       mysqld.cnf        bind-address = 127.0.0.1
#   Redis       redis.conf        bind 127.0.0.1 -::1   and   protected-mode yes
#   MongoDB     mongod.conf       net.bindIp: 127.0.0.1
Find risky permissions
# setuid and setgid binaries: compare against a known-good list
sudo find / -xdev \( -perm -4000 -o -perm -2000 \) -type f -printf '%M %u %p\n' 2>/dev/null

# File capabilities, a quieter alternative to setuid
sudo getcap -r / 2>/dev/null

# World-writable directories without the sticky bit, and world-writable files
sudo find / -xdev -type d -perm -0002 ! -perm -1000 2>/dev/null
sudo find / -xdev -type f -perm -0002 ! -path '/proc/*' 2>/dev/null

# Files with no owner, often left behind by removed users or attackers
sudo find / -xdev \( -nouser -o -nogroup \) 2>/dev/null

systemd can sandbox any service with a few directives, and it scores how exposed each unit is. Run systemd-analyze security to see the list, then add a drop-in for your own application services. Start strict and relax one setting at a time if the service fails, checking journalctl for the reason.

sudo systemctl edit myapp.service
[Service]
User=myapp
Group=myapp
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/myapp /var/log/myapp
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
CapabilityBoundingSet=
AmbientCapabilities=
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
UMask=0077
Check the result
sudo systemctl daemon-reload && sudo systemctl restart myapp
systemd-analyze security myapp.service       # aim for an exposure score under 3
systemd-analyze security                     # every unit, worst first

Add MemoryDenyWriteExecute=yes for compiled services, but leave it off for Node.js, Java and other runtimes with a JIT compiler, which need writable and executable memory. If the service must bind to port 80 or 443 as a non-root user, grant exactly that with AmbientCapabilities=CAP_NET_BIND_SERVICE and the same value in CapabilityBoundingSet.

Mandatory access control: AppArmor and SELinux

AppArmor on Ubuntu and Debian, and SELinux on RHEL, confine processes to the files and capabilities their policy allows, even when they run as root. They are the reason many web application compromises stay contained to the web server. Keep them enabled and enforcing; the most common hardening mistake on RHEL is still turning SELinux off to make an error go away.

AppArmor on Ubuntu and Debian
sudo aa-status                               # profiles in enforce and complain mode
sudo apt install apparmor-utils apparmor-profiles apparmor-profiles-extra
sudo aa-enforce /etc/apparmor.d/usr.sbin.nginx   # if the profile exists
sudo aa-genprof /usr/local/bin/myapp             # build a profile interactively
sudo aa-logprof                                  # refine it from logged denials
sudo journalctl -k | grep -i apparmor | tail
SELinux on RHEL, Rocky and Alma
getenforce && sestatus                       # expect Enforcing, targeted policy
sudo ausearch -m AVC -ts recent | audit2why  # explain recent denials

# Fix the context instead of disabling SELinux
sudo semanage fcontext -a -t httpd_sys_content_t '/srv/www(/.*)?'
sudo restorecon -Rv /srv/www
sudo setsebool -P httpd_can_network_connect on
sudo semanage port -a -t ssh_port_t -p tcp 2222

Logging and auditing

When something goes wrong, the questions are always the same: who logged in, what did they run, what changed. Make sure the server records the answers, keeps them long enough, and sends a copy somewhere an attacker with root cannot edit.

Persistent journald · /etc/systemd/journald.conf.d/10-persist.conf
[Journal]
Storage=persistent
Compress=yes
SystemMaxUse=2G
MaxRetentionSec=90day

sudo mkdir -p /var/log/journal
sudo systemctl restart systemd-journald
journalctl --disk-usage

The Linux audit framework records security-relevant system calls in the kernel, below anything a user-space rootkit can easily hide from. A focused rule set covering identity files, SSH and sudo configuration, privileged commands and kernel module loading gives you most of the investigative value without flooding the disk. The community rule sets from Florian Roth and the auditd examples in the CIS benchmarks are good starting points.

/etc/audit/rules.d/50-hardening.rules
-D
-b 8192
-f 1

# Identity and authentication
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers
-w /etc/ssh/sshd_config -p wa -k sshd
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
-w /etc/pam.d/ -p wa -k pam

# Persistence locations
-w /etc/crontab -p wa -k cron
-w /etc/cron.d/ -p wa -k cron
-w /var/spool/cron/ -p wa -k cron
-w /etc/systemd/system/ -p wa -k systemd
-w /etc/ld.so.preload -p wa -k preload

# Commands run as root by a real logged-in user
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=unset -k root_cmd
-a always,exit -F arch=b32 -S execve -F euid=0 -F auid>=1000 -F auid!=unset -k root_cmd

# Kernel modules
-a always,exit -F arch=b64 -S init_module,finit_module,delete_module -k modules

# Make the rules immutable until reboot (keep last)
-e 2
Load and query audit rules
sudo apt install auditd audispd-plugins     # RHEL: installed by default
sudo augenrules --load
sudo auditctl -s                            # "enabled 2" means locked
sudo auditctl -l

sudo ausearch -k identity -i --start today
sudo ausearch -k root_cmd -i --start recent
sudo aureport --auth --summary
sudo aureport -x --summary                  # most-run executables

Logs on the server are only trustworthy until the server is compromised. Forward them in near real time to a SIEM or a dedicated log host over TLS. The Wazuh agent, Elastic Agent, Splunk Universal Forwarder and Vector all work well; plain rsyslog is enough if you only need a remote copy.

/etc/rsyslog.d/90-forward.conf · apt install rsyslog-gnutls
global(DefaultNetstreamDriverCAFile="/etc/ssl/certs/log-ca.pem")

action(
  type="omfwd"
  target="logs.example.com" port="6514" protocol="tcp"
  StreamDriver="gtls" StreamDriverMode="1"
  StreamDriverAuthMode="x509/name"
  StreamDriverPermittedPeers="logs.example.com"
  queue.type="LinkedList" queue.filename="fwd" queue.saveOnShutdown="on"
  action.resumeRetryCount="-1"
)

File integrity and malware checks

File integrity monitoring answers the question an attacker most wants you not to ask: what changed on disk? AIDE builds a database of hashes and attributes for system files and reports differences. Build the database on a clean system, keep a copy off the server, and run checks daily.

AIDE
# Ubuntu and Debian
sudo apt install aide
sudo aideinit
sudo cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db
sudo aide --check --config /etc/aide/aide.conf

# RHEL, Rocky and Alma
sudo dnf install aide
sudo aide --init
sudo mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz
sudo aide --check

# After an intended change such as patching, accept the new state
sudo aide --update --config /etc/aide/aide.conf

Package managers can also verify installed files against their own checksums, which is a fast way to spot a replaced binary such as a backdoored sshd or ps. Rootkit scanners add checks for known rootkit files, hidden processes and suspicious kernel modules. They produce false positives, so baseline them on a clean system and investigate changes rather than every warning.

Package verification and rootkit scanners
sudo apt install debsums && sudo debsums -s      # changed package files only
sudo rpm -Va | grep -E '^..5'                    # RHEL: files whose hash changed

sudo apt install rkhunter chkrootkit
sudo rkhunter --update && sudo rkhunter --propupd # baseline on a clean system
sudo rkhunter --check --sk --rwo                 # report warnings only
sudo chkrootkit -q

# ClamAV for upload directories and shared storage
sudo apt install clamav && sudo freshclam
clamscan -r -i /srv/uploads

Publishing services safely

Most VPS workloads end up behind a web server. Terminate TLS in a reverse proxy, keep application servers on localhost, and send the headers that tell browsers to enforce HTTPS and refuse framing and content sniffing.

Nginx reverse proxy essentials
sudo apt install nginx certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run

# /etc/nginx/conf.d/hardening.conf
server_tokens off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_tickets off;
client_max_body_size 10m;
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;

# inside the server block
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
    limit_req zone=perip burst=20 nodelay;
    proxy_pass http://127.0.0.1:3000;
}

Test the result from outside with testssl.sh or the Qualys SSL Labs scanner, and run nginx -t before every reload. Keep admin panels such as phpMyAdmin, Grafana or a CMS backend off the public listener entirely; reach them through the VPN or an SSH tunnel.

If you run Docker

  • Membership in the docker group is equivalent to root on the host. Give it to nobody you would not give sudo to, and never mount /var/run/docker.sock into a container that faces the internet.
  • Publish ports on loopback, for example 127.0.0.1:8080:8080, and expose only the reverse proxy. Remember that published ports bypass UFW.
  • Run containers as a non-root user, with a read-only root file system, all capabilities dropped and no-new-privileges set. Add back only what the image needs.
  • Scan images for known vulnerabilities in CI and on the host with Trivy or Grype, and rebuild on a schedule so base image fixes reach production.
  • Consider rootless Docker or Podman for workloads that do not need privileged features.
/etc/docker/daemon.json and a hardened container
{
  "icc": false,
  "no-new-privileges": true,
  "live-restore": true,
  "userland-proxy": false,
  "log-driver": "local",
  "log-opts": { "max-size": "20m", "max-file": "5" }
}

docker run -d --name api \
  --user 10001:10001 --read-only --tmpfs /tmp \
  --cap-drop ALL --security-opt no-new-privileges \
  --memory 512m --pids-limit 200 \
  -p 127.0.0.1:8080:8080 registry.example.com/api:1.4.2

trivy image registry.example.com/api:1.4.2

Backups and the provider account

Hardening reduces the chance of a compromise; backups decide how bad one is. Follow the 3-2-1 rule: three copies, on two different kinds of storage, one of them off-site and out of reach of the server credentials. A provider snapshot is convenient, but it lives in the same account an attacker may already control.

Encrypted, deduplicated backups with restic
sudo apt install restic
export RESTIC_REPOSITORY="s3:https://s3.eu-central-1.amazonaws.com/acme-backups/web-01"
export RESTIC_PASSWORD_FILE=/root/.restic-pass        # chmod 600, also stored in your vault
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... # a key that can write, not delete

restic init
restic backup /etc /var/lib/myapp /srv --exclude-caches
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
restic check
restic restore latest --target /tmp/restore-test --include /etc/nginx
  • Enable object lock or versioning on the backup bucket so ransomware with the server credentials cannot delete history. Run pruning from a separate, more trusted machine.
  • Dump databases with their native tools (pg_dump, mysqldump, mongodump) before the file backup runs, rather than copying live data files.
  • Test a full restore to a new VPS at least once a quarter, and time it. That number is your real recovery time.
  • Protect the provider account itself: hardware-key MFA for every user, no shared logins, scoped API tokens, and alerts for console logins and new API keys. Whoever controls the account can reset root, read disks and delete snapshots without touching SSH.

Verify the hardening

A hardening change you have not tested is a hope, not a control. Check the server from inside with an auditing tool, from outside with a scanner, and against a published benchmark if you have compliance obligations.

Lynis: local audit with a hardening index
sudo apt install lynis            # or clone the latest from GitHub
sudo lynis audit system
sudo grep -E 'warning|suggestion' /var/log/lynis-report.dat | head -50
sudo grep hardening_index /var/log/lynis-report.dat
From outside: what does the internet see?
nmap -Pn -sS -p- --min-rate 2000 203.0.113.10       # all TCP ports
nmap -Pn -sV -sC -p 22,80,443 203.0.113.10            # versions and default scripts
nmap -Pn -sU --top-ports 50 203.0.113.10              # common UDP services
nmap -6 -Pn -sS -p- 2001:db8::10                      # do not forget IPv6
testssl.sh https://example.com
ssh-audit 203.0.113.10
CIS benchmark scans
# RHEL, Rocky and Alma: OpenSCAP with the SCAP Security Guide
sudo dnf install openscap-scanner scap-security-guide
sudo oscap xccdf eval \
  --profile xccdf_org.ssgproject.content_profile_cis_server_l1 \
  --results /root/cis-results.xml --report /root/cis-report.html \
  /usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml

# Ubuntu Pro: the Ubuntu Security Guide
sudo pro enable usg && sudo apt install usg
sudo usg audit cis_level1_server

Do not apply a full CIS profile with automatic remediation to a running production server without testing it on a copy first. Benchmarks are written for a generic machine, and a few controls will break something your workload depends on. Record each exception and the reason, which is exactly what an auditor will ask for.

Keep it hardened

Servers drift. Packages add services, colleagues open ports for a quick test, and keys of former staff stay in authorized_keys for years. Put the checks on a schedule and, where possible, encode the whole configuration in Ansible, cloud-init or another configuration management tool so a rebuilt server comes back hardened by default.

Hardening maintenance schedule
FrequencyTaskHow
DailySecurity updates installedunattended-upgrades or dnf-automatic, mail on change
DailyIntegrity and log reviewAIDE check, Fail2ban or CrowdSec summary, SIEM alerts
WeeklyPending reboots and restartsreboot-required file, needrestart, needs-restarting
MonthlyExternal exposurenmap of all addresses, testssl.sh, ssh-audit
MonthlyAccess reviewUsers, sudoers, authorized_keys, provider account members
QuarterlyBenchmark and restore testLynis or OpenSCAP report, full restore to a new VPS
YearlyKey and secret rotationSSH keys, API tokens, backup credentials, TLS keys

Hardening checklist

  1. Snapshot taken, system fully patched, hostname set, clock on UTC and synchronized.
  2. Named admin accounts with sudo, root password locked, sudo logging and use_pty enabled.
  3. SSH accepts keys only, no root login, restricted to an allowed group, modern algorithms, verified with ssh-audit.
  4. Host firewall denies inbound by default on IPv4 and IPv6, with a provider firewall in front.
  5. Fail2ban or CrowdSec running and banning.
  6. Automatic security updates with scheduled reboots, and needrestart installed.
  7. sysctl hardening applied, unused kernel modules blocked, /dev/shm and /tmp mounted nosuid and nodev.
  8. Every listening port justified, local services bound to loopback, setuid files reviewed.
  9. Application services sandboxed with systemd directives and running as their own users.
  10. AppArmor or SELinux enforcing.
  11. Persistent journald, auditd rules loaded and locked, logs forwarded off the host over TLS.
  12. AIDE baseline stored off the server and checked daily.
  13. TLS terminated in a reverse proxy with security headers; admin interfaces reachable only over VPN.
  14. Encrypted off-site backups with deletion protection, and a tested restore.
  15. Provider account protected with hardware-key MFA and alerting.
  • Linux
  • Hardening
  • VPS
  • SSH
  • Firewall
  • nftables
  • auditd
  • SELinux
  • AppArmor
  • CIS Benchmark
  • Security Leadership22 min read

    You Will Get Hacked. Your Job Is to Make It Expensive.

    Perfect security does not exist, and AI is cutting the cost of attack. A CISO’s guide to the threat cost ladder, the board conversation and raising the price of compromising you.

  • Incident Response25 min read

    Windows Cyber Triage for Incident Response

    A hands-on triage playbook for compromised Windows hosts: memory capture, live response, persistence hunting, event logs, execution artifacts, KAPE and Velociraptor collection.

  • Incident Response22 min read

    Linux Cyber Triage for Incident Response

    A hands-on triage playbook for compromised Linux servers and containers: memory capture, /proc analysis, SSH keys, logs, persistence hunting, rootkit checks and UAC collection.