Pillar Guide

VPS Security Hardening Guide 2026 — Complete Linux Server Checklist

A fresh VPS is not a secure VPS. Default configurations leave dozens of attack surfaces open. This guide walks through every layer of VPS security hardening: from SSH keys and firewall rules to ModSecurity WAF, Docker security, database hardening, intrusion detection, and encrypted backups. Every command is copy-paste ready. Work through these sections in order for a properly hardened production server.

By Alex Chen Updated March 15, 2026 45 min read 30+ code blocks
Before You Begin: Work through this guide on a test VPS first if possible. Several steps (firewall configuration, SSH changes) can lock you out if misconfigured. Always keep a VNC/console session open as a fallback, and test changes in a separate terminal before closing your current session.

Security Priority Order

  1. SSH hardening — Keys only, no root, change port
  2. Firewall (UFW) — Deny all, allow only needed ports
  3. Fail2ban — Auto-ban brute-force attempts
  4. Automatic updates — Security patches without manual work
  5. Application security — ModSecurity, database hardening
  6. Monitoring — Detect intrusions before they cause damage

1. Security Threat Model

Understanding what attackers want from your VPS helps you prioritize defenses. Your server is a target for:

What Attackers Want

  • Botnet membership: Your server's CPU and bandwidth to run DDoS attacks, send spam, or perform credential stuffing attacks against other sites. This is the most common goal for automated scanners.
  • Cryptocurrency mining: Cryptomining malware silently uses your CPU for months. The first sign is often an unexplained spike in CPU usage or an unusually high cloud bill. Tools like XMRig are commonly deployed.
  • Spam relay: If your server's email isn't properly configured, attackers use it to send millions of spam emails. This gets your IP blacklisted and can result in your VPS being suspended by the provider.
  • Data theft: Stealing customer data, credentials, API keys, and database contents for sale or ransomware leverage. Even small sites hold valuable data: email addresses, hashed passwords, payment info.
  • Ransomware: Encrypting your data and demanding payment. VPS with accessible databases and no backups are prime ransomware targets.
  • Pivot point: Using your VPS to attack internal networks or other servers, hiding the true source of attacks behind your IP address.

Who Is Attacking

The majority of attacks on VPS are fully automated. Bots scan the entire IPv4 address space continuously — your server will receive its first SSH probe attempt within minutes of being provisioned. These automated attackers are looking for low-hanging fruit: default passwords, unpatched software, open unnecessary ports. A properly hardened VPS is almost never targeted by sophisticated, human-directed attacks unless you are running something specifically high-value.

The good news: basic hardening stops 99%+ of attacks. Most of this guide covers exactly that baseline. For recommended secure providers, see our reviews of Vultr and Hetzner, which include DDoS protection and network-level filtering. For full server management without handling security yourself, consider ScalaHosting's managed plans.

2. Initial Server Hardening

The first minutes after provisioning are the most critical. Before installing any applications, run through these steps. Start from our VPS setup guide for system update steps, then return here for security hardening.

2.1 Create a Non-Root User

# Create a new administrative user (replace 'deploy' with your username)
adduser deploy

# Add to sudo group
usermod -aG sudo deploy

# Create SSH directory for new user
mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh

# Copy root's authorized keys to new user
cp /root/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys

# Verify sudo access — OPEN A NEW TERMINAL and test:
# ssh deploy@YOUR_IP
# sudo whoami   # Must return: root

2.2 Disable Root SSH Login

# Edit SSH daemon configuration
sudo nano /etc/ssh/sshd_config

# Find and set these values:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
ChallengeResponseAuthentication no
UsePAM yes
X11Forwarding no
PrintMotd no
MaxAuthTries 3
MaxSessions 5
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2

# Test config before applying (catches syntax errors)
sudo sshd -t

# Reload SSH (keeps current sessions alive, unlike restart)
sudo systemctl reload sshd

2.3 Change the SSH Port

# Choose a port number above 1024 (e.g., 2222, 4444, 49152)
# In /etc/ssh/sshd_config, find the Port line:
Port 2222

# Update UFW BEFORE reloading SSH
sudo ufw allow 2222/tcp
sudo ufw delete allow 22/tcp   # Remove old SSH rule

# Reload SSH daemon
sudo sshd -t && sudo systemctl reload sshd

# Test in a NEW terminal with the new port:
# ssh -p 2222 deploy@YOUR_IP

# Update fail2ban SSH jail port (if already configured)
# In /etc/fail2ban/jail.local under [sshd]:
# port = 2222

3. SSH Key Authentication

SSH keys are cryptographically stronger than any password. An Ed25519 key provides 128-bit security equivalent with shorter key sizes and faster operations than RSA-4096.

Generate SSH Keys (Local Machine)

# On your LOCAL machine (not the server)
# Generate Ed25519 key (recommended — fastest, most secure in 2026)
ssh-keygen -t ed25519 -C "vps-deploy-key-$(date +%Y)" -f ~/.ssh/id_ed25519_vps

# Or RSA-4096 if Ed25519 is not supported by your tools
ssh-keygen -t rsa -b 4096 -C "vps-deploy-key-$(date +%Y)" -f ~/.ssh/id_rsa_vps

# View public key (this is what goes on the server)
cat ~/.ssh/id_ed25519_vps.pub

# Example output:
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... vps-deploy-key-2026
# Copy public key to server
ssh-copy-id -i ~/.ssh/id_ed25519_vps.pub deploy@YOUR_SERVER_IP

# Or manually append to authorized_keys:
cat ~/.ssh/id_ed25519_vps.pub | ssh deploy@YOUR_SERVER_IP \
  "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

# Verify key works BEFORE disabling password auth
ssh -i ~/.ssh/id_ed25519_vps deploy@YOUR_SERVER_IP echo "Key auth works"
# Configure SSH client on local machine for convenience
# Add to ~/.ssh/config:
Host myserver
    HostName YOUR_SERVER_IP
    User deploy
    Port 2222
    IdentityFile ~/.ssh/id_ed25519_vps
    ServerAliveInterval 60

# Now connect simply with:
# ssh myserver

Rotate SSH Keys Periodically

# Generate a new key pair
ssh-keygen -t ed25519 -C "vps-deploy-key-2026-q2" -f ~/.ssh/id_ed25519_vps_new

# Add new key to server BEFORE removing the old one
cat ~/.ssh/id_ed25519_vps_new.pub | ssh myserver "cat >> ~/.ssh/authorized_keys"

# Test new key works
ssh -i ~/.ssh/id_ed25519_vps_new deploy@YOUR_SERVER_IP echo "New key works"

# Remove old key from server's authorized_keys
# (edit ~/.ssh/authorized_keys on the server and delete the old key line)

# Update local SSH config to use new key

4. Firewall Configuration: UFW

UFW (Uncomplicated Firewall) is the standard firewall management tool on Ubuntu/Debian. It wraps iptables in a simpler interface. Always configure UFW before enabling it — a single mistake can lock you out.

# Set default policies (deny all incoming, allow all outgoing)
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw default deny forward   # Disable packet forwarding unless needed

# Allow your custom SSH port (CRITICAL: do this BEFORE enabling)
sudo ufw allow 2222/tcp comment 'SSH'

# Allow web traffic
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'

# Enable UFW
sudo ufw enable

# Verify all rules
sudo ufw status verbose
# Rate-limit SSH to slow brute-force (allows 6 connections per 30s per IP)
sudo ufw limit 2222/tcp comment 'SSH rate limit'

# Allow access from specific IP only (most secure — use for admin tools)
sudo ufw allow from 203.0.113.1 to any port 8080 comment 'Admin panel - office only'

# Allow a range of IPs (e.g., your office's /24 network)
sudo ufw allow from 192.168.1.0/24 to any port 2222

# Block a specific IP address permanently
sudo ufw deny from 1.2.3.4 comment 'Known bad actor'

# Block an entire country's IP range (use ipset for efficiency)
# See: https://www.cyberciti.biz/faq/block-specific-country-with-ufw-linux/

# Show rules with numbers (for deletion)
sudo ufw status numbered

# Delete a rule by number
sudo ufw delete 3

# Disable UFW temporarily (emergency recovery)
sudo ufw disable

5. Firewall Configuration: iptables

iptables provides lower-level control than UFW. Use these rules for advanced scenarios: connection rate limiting, port knocking, or when UFW is not available. iptables rules are not persistent across reboots without additional configuration.

# Install iptables-persistent to save rules across reboots
sudo apt install iptables-persistent -y

# View current rules
sudo iptables -L -n -v

# Flush all existing rules (careful on production!)
# sudo iptables -F

# Basic iptables ruleset for a web server:
# Allow established connections
sudo iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# Allow loopback
sudo iptables -A INPUT -i lo -j ACCEPT

# Allow SSH (custom port)
sudo iptables -A INPUT -p tcp --dport 2222 -m state --state NEW -j ACCEPT

# SSH rate limiting (max 3 new connections per minute per IP)
sudo iptables -A INPUT -p tcp --dport 2222 -m state --state NEW \
  -m recent --set --name SSH
sudo iptables -A INPUT -p tcp --dport 2222 -m state --state NEW \
  -m recent --update --seconds 60 --hitcount 4 --name SSH -j DROP

# Allow HTTP and HTTPS
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT

# Allow ICMP ping (useful for diagnostics, can be blocked if desired)
sudo iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT

# Drop everything else
sudo iptables -A INPUT -j DROP

# Save rules (persists across reboots with iptables-persistent)
sudo netfilter-persistent save
# View saved rules
cat /etc/iptables/rules.v4

# Restore saved rules manually
sudo iptables-restore < /etc/iptables/rules.v4

# Create a complete iptables script for reproducible deployment:
sudo tee /usr/local/bin/apply-iptables.sh << 'EOF'
#!/bin/bash
# Reset
iptables -F
iptables -X
iptables -Z

# Default policies
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT

# Allow established
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Loopback
iptables -A INPUT -i lo -j ACCEPT

# SSH with rate limiting
iptables -A INPUT -p tcp --dport 2222 -m conntrack --ctstate NEW \
  -m limit --limit 3/min --limit-burst 6 -j ACCEPT

# Web
iptables -A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT

# ICMP ping (limit to prevent ping flood)
iptables -A INPUT -p icmp --icmp-type echo-request \
  -m limit --limit 1/s --limit-burst 5 -j ACCEPT

echo "iptables rules applied"
EOF
sudo chmod +x /usr/local/bin/apply-iptables.sh

6. Fail2Ban Setup & Custom Jails

Fail2Ban monitors log files for patterns indicating attacks and automatically bans offending IPs. It is essential for any internet-facing server. See also our VPS security guide for context on why automated scanning makes fail2ban critical.

# Install fail2ban
sudo apt install fail2ban -y

# Create local config (never edit jail.conf — it gets overwritten on update)
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
# /etc/fail2ban/jail.local — Complete configuration

[DEFAULT]
# Ban duration: 24 hours
bantime  = 86400
# Time window for counting failures
findtime = 600
# Number of failures before ban
maxretry = 3
# Send email alerts (configure your mail server first)
destemail = admin@yourdomain.com
sender    = fail2ban@yourdomain.com
action = %(action_mwl)s   # Ban + email with whois info and log lines

# Ban backend (auto-detects best available)
banaction = iptables-multiport
backend = auto

[sshd]
enabled  = true
port     = 2222
logpath  = %(sshd_log)s
maxretry = 3
bantime  = 86400

[nginx-http-auth]
enabled  = true
port     = http,https
logpath  = %(nginx_error_log)s

[nginx-botsearch]
enabled  = true
port     = http,https
logpath  = %(nginx_access_log)s
maxretry = 2
bantime  = 86400
# Create a custom jail for WordPress login brute-force
# /etc/fail2ban/filter.d/wordpress.conf:
sudo tee /etc/fail2ban/filter.d/wordpress.conf << 'EOF'
[Definition]
failregex = ^.*POST /wp-login.php
ignoreregex =
EOF

# Add WordPress jail to jail.local:
# [wordpress]
# enabled  = true
# port     = http,https
# filter   = wordpress
# logpath  = /var/log/nginx/access.log
# maxretry = 5
# bantime  = 86400

# Start and enable fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

# Check status of all jails
sudo fail2ban-client status

# Check SSH jail specifically
sudo fail2ban-client status sshd

# View banned IPs
sudo fail2ban-client banned

# Manually ban an IP
sudo fail2ban-client set sshd banip 1.2.3.4

# Unban an IP
sudo fail2ban-client set sshd unbanip 1.2.3.4

7. Automatic Security Updates

Manual updates get forgotten. Automatic security updates ensure critical patches are applied within hours of release without human intervention. For non-security updates (new features), manual review is still recommended.

# Install unattended-upgrades
sudo apt install unattended-upgrades apt-listchanges -y

# Configure (interactive)
sudo dpkg-reconfigure -plow unattended-upgrades
# /etc/apt/apt.conf.d/50unattended-upgrades — full configuration
sudo nano /etc/apt/apt.conf.d/50unattended-upgrades

# Key settings (paste or verify these):
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}";
    "${distro_id}:${distro_codename}-security";
    "${distro_id}ESMApps:${distro_codename}-apps-security";
    "${distro_id}ESM:${distro_codename}-infra-security";
};

Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";   # Set true if you want auto-reboot
Unattended-Upgrade::Automatic-Reboot-Time "02:00";
# Email notifications:
Unattended-Upgrade::Mail "admin@yourdomain.com";
Unattended-Upgrade::MailReport "on-change";
# /etc/apt/apt.conf.d/20auto-upgrades — enable auto-upgrades
sudo tee /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";
EOF

# Test the configuration (dry run)
sudo unattended-upgrade --dry-run --debug

# Check upgrade log
cat /var/log/unattended-upgrades/unattended-upgrades.log

8. ModSecurity Web Application Firewall

ModSecurity is a WAF (Web Application Firewall) that inspects HTTP traffic and blocks common web attacks: SQL injection, XSS, path traversal, and more. The OWASP Core Rule Set is the standard ruleset. Note: ModSecurity can cause false positives with some applications — start in detection mode before switching to prevention.

# Install ModSecurity for Nginx
sudo apt install libmodsecurity3 libmodsecurity-dev -y
sudo apt install libnginx-mod-http-modsecurity -y

# Or compile Nginx with ModSecurity (for more control)
# See: https://docs.nginx.com/nginx-waf/

# Download OWASP Core Rule Set
cd /etc/nginx
sudo git clone https://github.com/coreruleset/coreruleset.git modsecurity-crs
sudo cp modsecurity-crs/crs-setup.conf.example modsecurity-crs/crs-setup.conf
# Create ModSecurity main config
sudo tee /etc/nginx/modsecurity.conf << 'EOF'
# Load ModSecurity
SecRuleEngine DetectionOnly   # Start in Detection mode; change to "On" for blocking

# Log settings
SecAuditEngine RelevantOnly
SecAuditLogParts ABIJDEFHZ
SecAuditLog /var/log/nginx/modsec_audit.log

# Request body handling
SecRequestBodyAccess On
SecRequestBodyLimit 13107200
SecRequestBodyNoFilesLimit 131072
SecRequestBodyLimitAction Reject

# Response body handling
SecResponseBodyAccess On
SecResponseBodyMimeType text/plain text/html text/xml
SecResponseBodyLimit 524288
SecResponseBodyLimitAction ProcessPartial

# Include OWASP Core Rule Set
Include /etc/nginx/modsecurity-crs/crs-setup.conf
Include /etc/nginx/modsecurity-crs/rules/*.conf
EOF
# Add ModSecurity to Nginx server block
# In /etc/nginx/sites-available/yourdomain.com:
server {
    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsecurity.conf;

    # ... rest of your server block
}

# Test Nginx config
sudo nginx -t

# Reload Nginx
sudo systemctl reload nginx

# Monitor ModSecurity alerts (detection mode)
sudo tail -f /var/log/nginx/modsec_audit.log

# Once confident no false positives, switch to blocking:
# Change: SecRuleEngine DetectionOnly
# To:     SecRuleEngine On

9. SSL/TLS Best Practices

HTTPS is mandatory. But not all HTTPS configurations are equal. Outdated cipher suites and TLS versions leave you vulnerable to downgrade attacks. Here is how to configure TLS correctly. See our SSL certificates on VPS guide for Let's Encrypt setup details.

# Install Certbot for Let's Encrypt
sudo apt install certbot python3-certbot-nginx -y

# Obtain SSL certificate
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

# Verify auto-renewal
sudo certbot renew --dry-run

# Check certificate details
sudo certbot certificates
# /etc/nginx/snippets/ssl-params.conf — strong TLS configuration
# Create this file and include it in your server blocks

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;

# Session settings
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_session_tickets off;

# HSTS — tell browsers to only use HTTPS for 2 years
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# OCSP stapling (speeds up TLS handshake)
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# Complete Nginx server block with SSL
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    include /etc/nginx/snippets/ssl-params.conf;

    root /var/www/yourdomain.com/html;
    index index.php index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;
    return 301 https://$host$request_uri;
}

# Test your SSL configuration at: https://www.ssllabs.com/ssltest/

10. Docker Security

Docker provides process isolation but is not a security boundary by default. Containers running as root can potentially escape under certain conditions. These configurations harden Docker significantly. For Docker VPS recommendations, see our best VPS for Docker guide and our Docker VPS setup guide.

# Install Docker using the official method
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor \
  -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io -y

# Add your user to docker group (avoids sudo for docker commands)
sudo usermod -aG docker deploy

# Configure Docker daemon security settings
sudo mkdir -p /etc/docker
# /etc/docker/daemon.json — secure Docker daemon configuration
sudo tee /etc/docker/daemon.json << 'EOF'
{
  "icc": false,
  "no-new-privileges": true,
  "userns-remap": "default",
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  },
  "live-restore": true,
  "default-ulimits": {
    "nofile": {
      "Name": "nofile",
      "Hard": 64000,
      "Soft": 64000
    }
  }
}
EOF

sudo systemctl restart docker
sudo systemctl status docker
# Run containers with security restrictions
# Never run with --privileged unless absolutely required

# Drop all capabilities, add only what's needed
docker run -d \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --read-only \
  --tmpfs /tmp \
  --no-new-privileges \
  --user 1000:1000 \
  --security-opt no-new-privileges:true \
  --security-opt apparmor=docker-default \
  nginx:alpine

# Scan a Docker image for vulnerabilities (install Trivy)
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
  | sudo sh -s -- -b /usr/local/bin
trivy image nginx:latest

# View container resource usage
docker stats

# Inspect container security profile
docker inspect CONTAINER_ID | grep -A5 "SecurityOpt"
# Docker network isolation — create separate networks per application
docker network create --driver bridge app1-net
docker network create --driver bridge app2-net

# containers in different networks cannot communicate
docker run -d --network app1-net --name app1 myapp1
docker run -d --network app2-net --name app2 myapp2

# Check UFW rules for Docker (Docker modifies iptables directly)
# To prevent Docker from bypassing UFW, add to /etc/ufw/after.rules:
# *filter
# :ufw-user-forward - [0:0]
# :DOCKER-USER - [0:0]
# -A DOCKER-USER -j ufw-user-forward
# COMMIT

11. Database Security

Databases are the highest-value targets on most web servers. Proper database security prevents data breaches even if your web application is compromised.

MySQL/MariaDB Security

# Run the security wizard (essential after installation)
sudo mysql_secure_installation
# Answer YES to all prompts:
# - Set root password: yes
# - Remove anonymous users: yes
# - Disallow root login remotely: yes
# - Remove test database: yes
# - Reload privilege tables: yes
# Bind MySQL to localhost only (prevent remote access)
# /etc/mysql/mariadb.conf.d/50-server.cnf
sudo nano /etc/mysql/mariadb.conf.d/50-server.cnf

# Find and set:
bind-address = 127.0.0.1
# Or for IPv6:
# bind-address = ::1

# Restart after change
sudo systemctl restart mariadb
# Principle of least privilege for database users
sudo mariadb

-- Create an application user with minimum required permissions
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'VeryStr0ng!Password#2026';

-- Grant only SELECT, INSERT, UPDATE, DELETE on the app database
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'appuser'@'localhost';

-- Do NOT grant: FILE, SUPER, GRANT OPTION, RELOAD, PROCESS
-- These allow privilege escalation or server-level damage

-- Create a read-only reporting user
CREATE USER 'reporter'@'localhost' IDENTIFIED BY 'AnotherStr0ng!Pass#456';
GRANT SELECT ON appdb.* TO 'reporter'@'localhost';

-- Check user privileges
SHOW GRANTS FOR 'appuser'@'localhost';

-- Verify no anonymous users exist
SELECT user, host FROM mysql.user WHERE user = '';

FLUSH PRIVILEGES;
EXIT;
# Enable MariaDB audit logging
# In /etc/mysql/mariadb.conf.d/50-server.cnf:
plugin-load-add = server_audit
server_audit_logging = ON
server_audit_file_path = /var/log/mysql/audit.log
server_audit_file_rotate_size = 10M
server_audit_file_rotations = 5
server_audit_events = CONNECT,QUERY_DDL,QUERY_DCL

# Check audit log for suspicious queries
sudo tail -f /var/log/mysql/audit.log

PostgreSQL Security

# PostgreSQL bind to localhost
# /etc/postgresql/15/main/postgresql.conf:
listen_addresses = 'localhost'

# Configure pg_hba.conf for local-only access
# /etc/postgresql/15/main/pg_hba.conf:
# local   all    all                     peer
# host    all    all    127.0.0.1/32     scram-sha-256

# Create limited-privilege app user
sudo -u postgres psql
CREATE USER appuser WITH PASSWORD 'StrongPassword!2026';
CREATE DATABASE appdb OWNER appuser;
GRANT CONNECT ON DATABASE appdb TO appuser;
\q

12. Intrusion Detection (AIDE & rkhunter)

Intrusion detection tools alert you to unauthorized changes on your server. AIDE monitors file integrity (detects modified system binaries). rkhunter scans for known rootkits and suspicious configurations.

AIDE File Integrity Monitor

# Install AIDE
sudo apt install aide aide-common -y

# Configure AIDE (review default config)
sudo nano /etc/aide/aide.conf

# Initialize the database (creates baseline snapshot — takes a few minutes)
sudo aideinit
# Database is created at /var/lib/aide/aide.db.new

# Move new database to active location
sudo mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db

# Run a check against baseline (run daily via cron)
sudo aide --check

# After intentional system changes (package updates), update the baseline:
sudo aide --update
sudo mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db
# Schedule daily AIDE checks
sudo tee /etc/cron.daily/aide-check << 'EOF'
#!/bin/bash
REPORT="/tmp/aide-report-$(date +%Y%m%d).txt"
aide --check > "$REPORT" 2>&1
if [ $? -ne 0 ]; then
    # Send alert email if changes detected
    mail -s "AIDE: File integrity changes detected on $(hostname)" \
      admin@yourdomain.com < "$REPORT"
fi
rm -f "$REPORT"
EOF
sudo chmod +x /etc/cron.daily/aide-check

rkhunter Rootkit Scanner

# Install rkhunter
sudo apt install rkhunter -y

# Update rkhunter database
sudo rkhunter --update

# Set baseline for system files
sudo rkhunter --propupd

# Run a scan (verbose output)
sudo rkhunter --check --sk

# Check only specific tests
sudo rkhunter --check --enable rootkits --sk

# View rkhunter log
sudo cat /var/log/rkhunter.log | grep -E "Warning|Found"

# Schedule weekly scans
(crontab -l 2>/dev/null; echo "0 3 * * 0 sudo rkhunter --check --sk --report-warnings-only --mail-on-warning admin@yourdomain.com") | crontab -
# Install chkrootkit as a second opinion
sudo apt install chkrootkit -y
sudo chkrootkit

# If chkrootkit reports "INFECTED":
# 1. Do NOT shut down the server (may destroy forensic evidence)
# 2. Take a snapshot via your provider's dashboard
# 3. Follow the incident response plan in Section 15

13. Log Management

Logs are your primary forensic tool when something goes wrong. Proper log management means: logs rotate so they do not fill your disk, critical events trigger alerts, and logs are shipped off-server so attackers cannot delete them.

# Verify logrotate is configured for key services
cat /etc/logrotate.d/nginx
cat /etc/logrotate.d/mysql-server

# Create custom logrotate for a new service
sudo tee /etc/logrotate.d/myapp << 'EOF'
/var/log/myapp/*.log {
    daily
    missingok
    rotate 30
    compress
    delaycompress
    notifempty
    create 0640 www-data adm
    sharedscripts
    postrotate
        systemctl reload nginx > /dev/null 2>&1 || true
    endscript
}
EOF

# Test logrotate config
sudo logrotate -d /etc/logrotate.d/myapp   # debug mode (dry run)
sudo logrotate -f /etc/logrotate.d/myapp   # force rotation
# Enable auditd for kernel-level audit trail
sudo apt install auditd audispd-plugins -y
sudo systemctl enable auditd
sudo systemctl start auditd

# Add audit rules for high-value events
sudo auditctl -w /etc/passwd -p wa -k identity        # Watch for user changes
sudo auditctl -w /etc/shadow -p wa -k identity        # Watch shadow file
sudo auditctl -w /etc/sudoers -p wa -k sudo_changes   # Watch sudoers
sudo auditctl -w /var/log/auth.log -p wa -k auth      # Auth log modifications
sudo auditctl -a always,exit -F arch=b64 -S execve -k exec_tracking  # Track all executions

# Make rules persistent
sudo nano /etc/audit/rules.d/audit.rules
# Add the same rules without -e (they are already enabled at boot)

# Query audit log
sudo ausearch -k identity --interpret
sudo ausearch -k sudo_changes --interpret
sudo ausearch -k exec_tracking --interpret | tail -20

14. Encrypted Backups

Backups stored unencrypted on remote servers expose your data if the backup server is compromised. GPG encryption ensures only you can read your backup data. See also our VPS backup strategies guide for off-site storage options.

# Generate a GPG key for backups (on your LOCAL machine or secure server)
gpg --full-generate-key
# Choose: RSA and RSA, 4096 bits, no expiry for backup keys
# Set a strong passphrase

# Export the public key (for encrypting backups on the server)
gpg --export --armor backup@yourdomain.com > backup-public.gpg

# Copy public key to your VPS
scp backup-public.gpg deploy@YOUR_SERVER_IP:~/
ssh deploy@YOUR_SERVER_IP "gpg --import ~/backup-public.gpg"
#!/bin/bash
# /usr/local/bin/encrypted-backup.sh — Encrypted backup script

BACKUP_DIR="/var/backups/encrypted"
DATE=$(date +%Y%m%d_%H%M%S)
GPG_RECIPIENT="backup@yourdomain.com"
REMOTE="backup@offsite.yourserver.com:/backups/"

mkdir -p "$BACKUP_DIR"

# Create backup archive
tar -czf - /var/www/ /etc/nginx/ /etc/mysql/ 2>/dev/null | \
  gpg --encrypt --recipient "$GPG_RECIPIENT" \
      --trust-model always \
      --output "$BACKUP_DIR/backup_$DATE.tar.gz.gpg"

# Database backup with encryption
mysqldump --all-databases --single-transaction | \
  gzip | \
  gpg --encrypt --recipient "$GPG_RECIPIENT" \
      --trust-model always \
      --output "$BACKUP_DIR/databases_$DATE.sql.gz.gpg"

# Transfer to remote off-site storage
rsync -az "$BACKUP_DIR/"*"$DATE"* "$REMOTE"

# Clean up local copies older than 7 days
find "$BACKUP_DIR" -name "*.gpg" -mtime +7 -delete

echo "$(date): Encrypted backup completed"
# Decrypt backup on local machine for restore
gpg --decrypt backup_20260315_020000.tar.gz.gpg | tar -xzf - -C /restore/

# Verify backup integrity
gpg --verify backup_20260315_020000.tar.gz.gpg

# Schedule encrypted backup (2 AM daily)
(crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/encrypted-backup.sh >> /var/log/encrypted-backup.log 2>&1") | crontab -

15. Incident Response Plan

When you suspect your VPS has been compromised, a clear plan prevents panic-driven mistakes that destroy forensic evidence or make things worse.

Signs of Compromise

  • Unexplained CPU spikes (cryptomining)
  • Unknown processes in htop or ps aux
  • Outbound connections to unknown IPs (ss -tulnp or netstat -an)
  • New user accounts in /etc/passwd
  • Modified system binaries (AIDE alert)
  • SSH login from unfamiliar geographic location in /var/log/auth.log
  • Your IP on spam blacklists (check at mxtoolbox.com)

Immediate Response Steps

  1. Do NOT reboot immediately. Running memory may contain forensic evidence (encryption keys, attack tools).
  2. Take a snapshot via your provider's dashboard to preserve the disk state for forensic analysis.
  3. Isolate the server. Use your provider's firewall or networking panel to block all inbound/outbound traffic except your IP. This stops ongoing attacks and exfiltration while preserving the running state.
  4. Collect evidence. Run: ps aux, netstat -tulnp, ss -an, last, lastb, who, w, df -h, copy logs.
  5. Identify the breach vector. Review auth logs, nginx access logs, application logs. Look for the initial unauthorized access.
  6. Provision a clean server. Never trust a compromised server fully. Deploy a new VPS, restore from a known-good backup taken before the compromise.
  7. Patch the vulnerability that allowed the compromise before bringing the new server online.
  8. Rotate all credentials: SSH keys, database passwords, API keys, application secrets.

16. Security Checklist (50+ Items)

Use this checklist for each new VPS deployment. Print it or save it. A hardened server should have every item checked.

Category Item Priority
SSH & Access
SSHRoot login disabled (PermitRootLogin no)Critical
SSHPassword authentication disabledCritical
SSHSSH key authentication enabled and testedCritical
SSHSSH port changed from default 22High
SSHMaxAuthTries set to 3High
SSHX11Forwarding disabledMedium
SSHLoginGraceTime set to 30 secondsMedium
AccessNon-root admin user created and testedCritical
Accesssudo group membership verifiedCritical
Firewall
UFWUFW enabled with default deny incomingCritical
UFWOnly required ports allowed (SSH, 80, 443)Critical
UFWSSH port rate-limitedHigh
UFWUFW rules reviewed and documentedMedium
Updates & Patching
Updatesunattended-upgrades installed and enabledCritical
UpdatesAll packages current after provisioningCritical
UpdatesKernel is current LTS versionHigh
Fail2Ban
Fail2Banfail2ban installed and runningCritical
Fail2BanSSH jail enabled with maxretry=3Critical
Fail2BanNginx jails configured (http-auth, botsearch)High
Fail2Banbantime set to 24h+High
SSL/TLS
SSLValid SSL certificate installedCritical
SSLHTTP redirects to HTTPSCritical
SSLTLS 1.2 and 1.3 only (1.0 and 1.1 disabled)Critical
SSLHSTS header enabledHigh
SSLAuto-renewal configured (certbot)Critical
SSLSecurity headers set (X-Frame-Options, CSP, etc.)High
Database
Databasemysql_secure_installation runCritical
Databasebind-address = 127.0.0.1Critical
DatabaseNo anonymous users in databaseCritical
DatabaseApp users have minimal required privilegesCritical
DatabaseTest database removedHigh
Monitoring & Detection
MonitoringAIDE initialized and scheduled daily checkHigh
Monitoringrkhunter installed and scheduled weeklyHigh
Monitoringauditd enabled and rules configuredMedium
MonitoringExternal uptime monitoring configuredHigh
MonitoringDisk usage alerts configured (>80%)High
Backups
BackupAutomated backup script running dailyCritical
BackupBackups stored off-site (not same server)Critical
BackupBackup encryption enabled (GPG)High
BackupRestore test completed successfullyCritical

17. Frequently Asked Questions

What is the most important VPS security step?

Disabling password-based SSH and switching to SSH key authentication. Bots try thousands of passwords per hour — with SSH keys, brute-force becomes computationally impossible. After that: UFW firewall and fail2ban. These three together stop the vast majority of automated attacks.

How do I know if my VPS has been compromised?

Warning signs: unexpected CPU spikes (cryptomining), unknown processes in htop, unfamiliar outbound connections (ss -tulnp), new user accounts in /etc/passwd, AIDE alerts about modified system files, successful SSH logins from unfamiliar IPs in /var/log/auth.log, and your IP on spam blacklists. Run sudo rkhunter --check for a rootkit scan.

Should I change the SSH port from 22?

It reduces automated bot traffic significantly since most scanners only probe port 22. However, it is security through obscurity — a full port scan finds any port. Change the port AND use SSH keys. Never rely on port obscurity alone. If you change the port, update UFW rules and fail2ban configuration accordingly.

Is UFW or iptables better for VPS security?

UFW is a frontend for iptables — not mutually exclusive. UFW is simpler and less error-prone for common tasks. Use UFW for day-to-day firewall management. For complex scenarios (custom packet filtering, connection tracking), configure iptables rules directly. On most VPS, UFW covers 95% of needs perfectly.

How often should I update my VPS?

Security updates: apply immediately via unattended-upgrades. Regular package upgrades: apply weekly. Major version upgrades: test in staging first, then schedule during a maintenance window. Never run an OS past its end-of-life date — it will no longer receive security patches. Ubuntu 24.04 LTS is supported until 2029.

What is fail2ban and do I need it?

Fail2ban monitors log files for failed login attempts and automatically bans offending IPs via iptables. Yes, you need it. Without fail2ban, bots continuously probe your server. With SSH keys AND fail2ban, you have defense in depth: even if a key were somehow leaked, the brute-force protection provides a second layer. Configuration takes 15 minutes and provides continuous protection.

Is Docker secure on a VPS?

Docker provides process isolation but is not a security boundary by itself. For secure Docker: use rootless Docker (daemon runs as non-root), enable user namespace remapping in /etc/docker/daemon.json, scan images with Trivy, use --read-only and --no-new-privileges flags, and never use --privileged unless absolutely required. Our Docker VPS guide covers all of these with exact commands. For Docker-optimized providers, see our best VPS for Docker guide.

Secure Your VPS Today

Work through this guide from top to bottom for a fully hardened server. Most critical steps take under 30 minutes total.

VPS Setup Guide Ultimate VPS Guide SSL Certificate Guide

Related Guides & Resources

AC
Alex Chen — Senior Systems Engineer

Published March 15, 2026

Alex Chen is a Senior Systems Engineer with 7+ years of experience in cloud infrastructure and VPS hosting. He has personally deployed and benchmarked 50+ VPS providers across US datacenters. Learn more about our testing methodology →