Skip to main content

Command Palette

Search for a command to run...

Hardening a Linux VPS: A practical guide beyond the checklist

Security is not a state. It's a practice.

Published
β€’15 min read
Hardening a Linux VPS: A practical guide beyond the checklist
P
Cybersecurity and infrastructure specialist with 15+ years in enterprise environments, SIEM operations, incident response, Linux hardening, and vulnerability assessment. I write practical notes from real-world experience: how systems actually break, why incidents really happen, and what works when theory isn't enough. Available for technical writing and selective consulting. πŸ“Located in Guatemala - English and Spanish

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

  2. Mapping your attack surface

  3. User Management & Privilege Hardening

  4. SSH Hardening, your front door

  5. Firewall configuration with UFW

  6. Intrusion Prevention with Fail2ban

  7. Kernel Hardening with sysctl

  8. File Integrity Monitoring with AIDE

  9. Log Management & Centralized Monitoring

  10. Backup strategy that actually works

  11. Putting it all together, The Layered Model


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/sudoers directly
Always use visudo to 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 edit jail.conf
jail.conf is 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-upgrades for 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.