Hardening a Linux VPS: A practical guide beyond the checklist
Security is not a state. It's a practice.

Most guides give you a list of steps to follow. This one tells you why each layer matters, what breaks when you skip it, and how to think like an attacker so you can defend like a professional.
Table of Contents
1. Why checklists aren't enough
The internet is full of those "VPS hardening checklists". Most of the advice is solid things like disabling root login, changing your SSH port, and enabling a firewall. Doing that is way better than doing nothing, for sure. But the thing with checklists is they give you this false sense of completeness, like you've checked all the boxes and now you're 100% safe.
The problem is this: security is not a state, it's a practice. A misconfigured sudoers file can undo every SSH restriction you've put in place. A plugin installed three months after you ran your checklist can open a backdoor that your firewall never knew about. A forgotten cron job running as root with world-writable output files is a privilege escalation waiting to happen.
This guide goes beyond the checklist. For each control, we'll explain the threat it mitigates, the attack path it closes, and how an attacker would try to get around it anyway, so you can think defensively, not reactively.
π Target Audience
This guide assumes a fresh Ubuntu 22.04 / Debian 12 VPS with root access. The concepts apply broadly to any systemd-based Linux distribution. Commands are tested on both Ubuntu Lightsail and DigitalOcean Droplets.
2. Mapping your attack surface
Before hardening anything, you need to understand what you're protecting. An attack surface is every point where an unauthorized actor could attempt to interact with your system.
On a typical Linux VPS running a web application, that surface is larger than most people assume.
The first practical step is to audit what's actually exposed on your server right now.
Run this from a separate machine or use a Nmap container:
# External port scan β run from your local machine, not the server
nmap -sV -sC -p- --min-rate 1000 YOUR_SERVER_IP
# From the server itself β list all listening services
ss -tlnp
# Who's running what as which user?
ps aux | grep -v '\[' | awk 'NR>1 {print \(1, \)11}' | sort -u
# Find world-writable files (potential injection points)
find / -xdev -type f -perm -002 2>/dev/null | grep -v proc
Document everything you see before touching any configuration. This baseline becomes your reference for detecting changes later.
3. User Management & Privilege Hardening
The most common post-exploitation path on Linux systems isn't a sophisticated kernel exploit, it's a misconfigured sudo rule or a forgotten service account with a shell.
Privilege management is your first internal defense layer.
Create a dedicated admin user and disable root login
# Create a new admin user
adduser adminsec
# Add to sudo group
usermod -aG sudo adminsec
# Copy SSH authorized keys to the new user
rsync --archive --chown=adminsec:adminsec ~/.ssh /home/adminsec
# Lock the root password (not just disable β lock it entirely)
passwd -l root
# Verify the lock applied
grep root /etc/shadow | cut -d: -f2
Audit sudoers, the most overlooked misconfiguration
A NOPASSWD:ALL entry for an application service account is a privilege escalation on rails.
Always scope sudo permissions to the exact binary needed:
# /etc/sudoers.d/webapp-user
# Edit with: visudo -f /etc/sudoers.d/webapp-user
# WRONG β never do this for service accounts
webuser ALL=(ALL) NOPASSWD:ALL
# RIGHT β restrict to exact commands, no shell access
webuser ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx
webuser ALL=(root) NOPASSWD: /usr/bin/systemctl restart php8.1-fpm
β οΈ Never edit
/etc/sudoersdirectly
Always usevisudoto edit sudo configuration. It validates syntax before saving, preventing a lockout from a typo. A broken sudoers file means no sudo access, ever.
Find and disable unnecessary accounts
# List all users with login shells
grep -v '/usr/sbin/nologin\|/bin/false' /etc/passwd
# Find accounts with UID 0 (root-equivalent) β should only be root
awk -F: '($3 == "0") {print}' /etc/passwd
# Disable a service account shell (don't delete β you may need the UID)
usermod -s /usr/sbin/nologin serviceaccount
# List users who can sudo
grep -Po '^sudo.+:\K.*$' /etc/group | tr ',' '\n'
4. SSH Hardening, your front door
SSH is the primary remote access method for any Linux server. It is also the most aggressively scanned service on the internet, bots will attempt to brute-force port 22 within minutes of a server going live.
The default OpenSSH configuration is functional, not secure.
Full SSH hardening configuration
# /etc/ssh/sshd_config
# ββ Port & Protocol ββββββββββββββββββββββββββββββββββββββ
Port 2222 # Non-standard port reduces automated scan noise
Protocol 2 # SSHv1 is broken β never SSHv1
AddressFamily inet # IPv4 only unless you need IPv6
# ββ Authentication ββββββββββββββββββββββββββββββββββββββββ
PermitRootLogin no
PasswordAuthentication no # Keys only. Always.
ChallengeResponseAuthentication no
UsePAM yes
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
# ββ Session Controls ββββββββββββββββββββββββββββββββββββββ
LoginGraceTime 30 # 30s to authenticate, then disconnect
MaxAuthTries 3 # 3 attempts, then disconnect
MaxSessions 5 # Limit concurrent sessions
ClientAliveInterval 300 # Heartbeat every 5 minutes
ClientAliveCountMax 2 # Disconnect after 2 missed heartbeats
# ββ Feature Restrictions ββββββββββββββββββββββββββββββββββ
X11Forwarding no # Disable unless specifically needed
AllowTcpForwarding no # Prevents tunneling abuse
AllowAgentForwarding no
PermitUserEnvironment no
PermitEmptyPasswords no
# ββ Restrict Access by User βββββββββββββββββββββββββββββββ
AllowUsers adminsec # Whitelist β only these users may SSH
# ββ Cryptographic Hardening βββββββββββββββββββββββββββββββ
KexAlgorithms curve25519-sha256,diffie-hellman-group16-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
# Validate configuration before restarting
sshd -t && echo "Config OK" || echo "Config ERROR β do not restart"
# Reload (not restart β keeps existing sessions alive)
systemctl reload ssh
# Test from a NEW terminal before closing the current session
ssh -p 2222 adminsec@YOUR_SERVER_IP
π΄ Critical: never close your current SSH session until you verify the new one works.
A misconfigured SSH restart without testing from a new terminal is the most common way to permanently lock yourself out of a VPS. Always have a backup access method (cloud console) ready before making changes.
Generate a strong SSH key pair (Ed25519)
# Generate Ed25519 key (preferred over RSA 2048 β faster, smaller, more secure)
ssh-keygen -t ed25519 -C "server@server-prod-01" -f ~/.ssh/id_ed25519_prod
# Copy to server
ssh-copy-id -i ~/.ssh/id_ed25519_prod.pub -p 2222 adminsec@YOUR_SERVER_IP
# Lock down permissions
chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys
5. Firewall Configuration with UFW
A firewall is not optional. Default deny all inbound, allow only what you explicitly need, this is the correct mental model.
UFW (Uncomplicated Firewall) sits on top of iptables and provides a human-readable interface for managing netfilter rules.
# CRITICAL: Set defaults BEFORE enabling β otherwise you lock yourself out
ufw default deny incoming
ufw default allow outgoing
# Allow SSH on custom port (adjust if you changed it)
ufw allow 2222/tcp comment 'SSH custom port'
# Web traffic
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
# Rate-limit SSH to prevent brute force (6 attempts/30s per IP)
ufw limit 2222/tcp
# Enable β confirm when prompted
ufw enable
# Verify rule set
ufw status verbose
Lock down internal services
# /etc/mysql/mysql.conf.d/mysqld.cnf
# MySQL should NEVER listen on a public interface
bind-address = 127.0.0.1
# /etc/redis/redis.conf β same principle
bind 127.0.0.1 ::1
6. Intrusion Prevention with Fail2ban
UFW's ufw limit is a blunt instrument, it rate-limits connections but doesn't ban IPs based on behavior. Fail2ban reads log files, matches patterns (failed SSH logins, WordPress auth failures, Nginx 403s), and dynamically inserts iptables DROP rules for offending IPs.
apt install fail2ban -y
systemctl enable fail2ban
systemctl start fail2ban
π‘ Always use
jail.local, never editjail.confjail.confis overwritten on updates. All customizations go in/etc/fail2ban/jail.local, it overrides the defaults cleanly.
# /etc/fail2ban/jail.local
[DEFAULT]
# How long to ban an offending IP (in seconds)
bantime = 3600
# Window to count failures (10 minutes)
findtime = 600
# Number of failures before ban
maxretry = 5
# Your own IPs β NEVER ban these (comma-separated)
ignoreip = 127.0.0.1/8 ::1 YOUR.HOME.IP.HERE
# ββ SSH jail ββββββββββββββββββββββββββββββββββββββββββββββ
[sshd]
enabled = true
port = 2222
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 86400 # 24h ban for SSH β be aggressive
# ββ Nginx β block scanners hitting 404s repeatedly ββββββββ
[nginx-limit-req]
enabled = true
port = http,https
filter = nginx-limit-req
logpath = /var/log/nginx/error.log
maxretry = 10
# ββ WordPress auth ββββββββββββββββββββββββββββββββββββββββ
[wordpress]
enabled = true
port = http,https
filter = wordpress
logpath = /var/log/nginx/access.log
maxretry = 5
bantime = 7200
Custom filter for WordPress brute force
# /etc/fail2ban/filter.d/wordpress.conf
[Definition]
# Match POST requests to wp-login.php and xmlrpc.php (common attack vectors)
failregex = ^<HOST> .* "POST /wp-login\.php
^<HOST> .* "POST /xmlrpc\.php
ignoreregex =
# Check status of all jails
fail2ban-client status
# Check specific jail (how many IPs banned, etc.)
fail2ban-client status sshd
# Manually unban an IP (e.g., yourself)
fail2ban-client set sshd unbanip 1.2.3.4
# Test a filter against a log file before enabling
fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf --print-all-matched
7. Kernel Hardening with sysctl
The Linux kernel exposes hundreds of tunable parameters via /proc/sys/. Many of the defaults prioritize compatibility over security.
Hardening the kernel via sysctl closes network-level attack vectors like IP spoofing, ICMP redirects, and SYN flood amplification, before any application code even runs.
# /etc/sysctl.d/99-hardening.conf
## ββ Network: IP spoofing & routing ββββββββββββββββββββββββββ
# Ignore ICMP redirects (used in MITM attacks)
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
# Don't send redirects (not a router)
net.ipv4.conf.all.send_redirects = 0
# Reverse path filtering β drops packets with a spoofed source IP
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Ignore ICMP broadcast pings (smurf amplification)
net.ipv4.icmp_echo_ignore_broadcasts = 1
# Disable source routing (allows packets to specify their own path)
net.ipv4.conf.all.accept_source_route = 0
## ββ SYN flood protection βββββββββββββββββββββββββββββββββββββ
# Enable SYN cookies β responds to SYN flood without allocating state
net.ipv4.tcp_syncookies = 1
# Increase backlog for SYN flood resilience
net.ipv4.tcp_max_syn_backlog = 2048
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 5
## ββ Log suspicious packets βββββββββββββββββββββββββββββββββββ
# Log martian (impossible source) packets
net.ipv4.conf.all.log_martians = 1
## ββ Kernel: memory protections βββββββββββββββββββββββββββββββ
# Restrict /proc/kallsyms visibility (kernel symbol leak)
kernel.kptr_restrict = 2
# Restrict dmesg to root (kernel message leak)
kernel.dmesg_restrict = 1
# Disable SysRq (magic system request key)
kernel.sysrq = 0
# Disable core dumps for setuid programs
fs.suid_dumpable = 0
# ASLR β Address Space Layout Randomization (full randomization)
kernel.randomize_va_space = 2
# Apply immediately without reboot
sysctl -p /etc/sysctl.d/99-hardening.conf
# Verify a specific parameter
sysctl net.ipv4.tcp_syncookies
8. File Integrity Monitoring with AIDE
Every other control in this guide tries to keep attackers out. File Integrity Monitoring (FIM) assumes they might get in and asks: how will you know?
AIDE (Advanced Intrusion Detection Environment) computes cryptographic hashes of files and compares them against a baseline, any modification triggers an alert.
apt install aide -y
# Initialize the database β this takes 5-10 minutes on a typical VPS
# It hashes every file in scope: binaries, configs, libraries
aideinit
# Move the generated database to the active location
cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db
# Run a manual check against baseline
aide --check
# /etc/cron.d/aide-check
# Nightly check with email alert
0 3 * * * root /usr/bin/aide --check | mail -s "[AIDE] Integrity check \((hostname) \)(date +\%F)" security@yourdomain.com
π‘ Update the database after intentional changes
After every planned system update or configuration change, update the AIDE database:aide --update && cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db. Otherwise, every nightly check will alert on your own changes.
FIM tool comparison
| Tool | Approach | Best for | Overhead |
|---|---|---|---|
| AIDE | Periodic hash comparison | Small/medium VPS, offline baseline | Low |
| Tripwire | Signed policy + hashes | Compliance environments, audit trails | Medium |
| Wazuh FIM | Real-time inotify-based | Centralized SIEM integration | Medium-High |
| auditd | Kernel-level syscall audit | Who changed what, when, with what PID | Low-Medium |
9. Log Management & Centralized Monitoring
Logs are your post-incident forensic trail. An attacker who gains root access will try to cover their tracks, the first thing they do is modify or delete local logs.
This means local logging alone is not enough. Centralize your logs to an external system that the server cannot write back to.
Critical logs to monitor
# Authentication events (SSH login, sudo, su)
/var/log/auth.log
# System messages
/var/log/syslog
# Package installations and removals
/var/log/dpkg.log
# Cron job execution
/var/log/cron.log
# Web server access and errors
/var/log/nginx/access.log
/var/log/nginx/error.log
# Fail2ban actions (bans and unbans)
/var/log/fail2ban.log
# Kernel messages (hardware, driver, security events)
/var/log/kern.log
Automated daily summary with logwatch
apt install logwatch -y
# Configure daily email report
cat > /etc/logwatch/conf/logwatch.conf << EOF
Output = mail
Format = html
MailTo = security@yourdomain.com
MailFrom = logwatch@$(hostname -f)
Detail = Med
Range = yesterday
EOF
# Run manually to test
logwatch --output stdout --format text --range today
Quick threat detection: patterns to grep for
# Failed SSH attempts with IP addresses
grep "Failed password" /var/log/auth.log | awk '{print $11}' | sort | uniq -c | sort -rn | head -20
# Successful logins β who actually got in?
grep "Accepted" /var/log/auth.log | awk '{print \(9, \)11}'
# New user accounts created
grep "new user" /var/log/auth.log
# Commands run with sudo
grep "sudo:" /var/log/auth.log | grep "COMMAND"
# PHP file execution (common for web shells)
grep "\.php" /var/log/nginx/access.log | grep -v "200\|304" | grep "POST"
# Processes that wrote to /tmp (malware staging area)
find /tmp -newer /var/log/syslog -type f 2>/dev/null
10. Backup strategy that actually works
Backups are the last line of defense. Not just against hardware failure, against ransomware, against accidental deletion, against a botched configuration change that breaks production at 2am.
The rule is simple: 3-2-1.
Automated backup script with retention
#!/bin/bash
# /usr/local/bin/vps-backup.sh
# VPS Backup Script with rotation
# Backs up web files, databases, and configs
set -euo pipefail
BACKUP_DIR="/var/backups/vps"
DATE=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=14
S3_BUCKET="s3://your-backup-bucket/$(hostname)"
mkdir -p "$BACKUP_DIR"
# ββ Web files βββββββββββββββββββββββββββββββββββββββββββββ
tar czf "\(BACKUP_DIR/webroot_\)DATE.tar.gz" \
--exclude='*.log' \
--exclude='./cache' \
/var/www/html/
# ββ MySQL databases βββββββββββββββββββββββββββββββββββββββ
mysqldump --all-databases \
--single-transaction \
--routines \
--triggers \
-u root \
| gzip > "\(BACKUP_DIR/mysql_\)DATE.sql.gz"
# ββ Config files ββββββββββββββββββββββββββββββββββββββββββ
tar czf "\(BACKUP_DIR/configs_\)DATE.tar.gz" \
/etc/nginx/ \
/etc/ssh/sshd_config \
/etc/fail2ban/ \
/etc/ufw/ \
/etc/sysctl.d/ \
/etc/cron.d/
# ββ Sync to S3 (offsite) ββββββββββββββββββββββββββββββββββ
aws s3 sync "\(BACKUP_DIR" "\)S3_BUCKET" --delete
# ββ Prune local backups older than retention period βββββββ
find "\(BACKUP_DIR" -type f -mtime +"\)RETENTION_DAYS" -delete
echo "[$(date)] Backup completed successfully" >> /var/log/vps-backup.log
# Schedule via cron β run daily at 2:30 AM
# /etc/cron.d/vps-backup
30 2 * * * root /usr/local/bin/vps-backup.sh 2>>/var/log/vps-backup.log
π΄ A backup you haven't tested is not a backup
Schedule a monthly restore test. Write a runbook that documents exactly how to restore from scratch. The worst moment to figure out your restore process is during an incident at 2am with a client on the phone.
11. Putting it all together, The Layered Model
Defense-in-depth is not about any single control. It's about ensuring that the failure of any one layer does not result in a complete compromise.
Here is the full picture of what we've built:
The goal is not perfection, no system is perfectly secure. The goal is to make attacking your server more expensive and noisier than it's worth, to detect compromises fast when they happen, and to recover cleanly when needed.
This is what it means to go beyond the checklist: not just implementing controls, but understanding the threat model behind each one, knowing what each control catches and what it doesn't, and treating security as an ongoing operational practice rather than a one-time configuration event.
π― Next steps to consider
For production environments: CIS Benchmark compliance scanning (Lynis), AppArmor/SELinux mandatory access control, centralized SIEM with Wazuh or Elastic Stack,unattended-upgradesfor automatic security patches, and network-level WAF (Cloudflare or AWS WAF) in front of web-facing services.
Written by Pablo Huertas - Cybersecurity Specialist & Technical Writer
15+ years in enterprise technical support and incident management.

