Securing a VPS in 2026: A No-Nonsense Hardening Guide
Every step ranked by priority — SSH, firewall, patching, backups, and TLS, with the exact configs
Senior Developer

Every step ranked by priority — SSH, firewall, patching, backups, and TLS, with the exact configs.
A default VPS install is not a secure one. The moment it gets a public IP, automated bots start probing it for weak SSH logins, open ports, and outdated packages — often within minutes. This guide is ordered by impact: do Tier 1 before anything touches production, Tier 2 within the first week, Tier 3 if it applies to your stack, and Tier 4 on a recurring schedule forever. Written for Ubuntu 24.04 LTS / Debian 12, with notes where RHEL-based systems (AlmaLinux, Rocky) differ.
Tier 1 — Before Anything Touches Production
1. Non-Root User + SSH Key Auth
# On the server, as root
adduser deploy
usermod -aG sudo deploy
# On your local machine — generate a modern key (Ed25519, not RSA)
ssh-keygen -t ed25519 -C "you@yourdomain.com" -a 100
# Copy it to the server
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@your-server-ip
# Log in as deploy, confirm sudo works, THEN lock root
sudo passwd -l root
Ed25519 keys are smaller, faster, and free of the timing side-channels that affect RSA. Use RSA only if you're stuck supporting a legacy client, and if so use at least 4096 bits.
2. Harden sshd_config
Keep a second terminal connected while you test — don't edit this over your only session. Edit /etc/ssh/sshd_config (or drop a file into /etc/ssh/sshd_config.d/):
# Authentication
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
PermitEmptyPasswords no
MaxAuthTries 3
MaxSessions 4
LoginGraceTime 30
AllowUsers deploy
# Modern crypto only — drop legacy KEX/ciphers/MACs
KexAlgorithms mlkem768x25519-sha256,curve25519-sha256,[email protected]
HostKeyAlgorithms ssh-ed25519,ecdsa-sha2-nistp256
Ciphers [email protected],[email protected],[email protected]
MACs [email protected],[email protected]
# Reduce attack surface
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
Notes:
mlkem768x25519-sha256is a hybrid classical/post-quantum key exchange (ML-KEM-768, FIPS 203) and is the default negotiated exchange on OpenSSH 9.9+. It protects against "harvest now, decrypt later" interception. If anything in your fleet runs older OpenSSH, drop that entry and lead withcurve25519-sha256.Restart safely:
sudo systemctl reload sshd(reload, not restart, keeps existing sessions alive if the config is bad).Run
sshd -T | lessto see the effective config, and audit it externally withssh-audit your-server-ip— it flags any weak KEX, cipher, or MAC still active.Changing the port off 22 doesn't stop a targeted attacker, but it silences almost all automated scanner noise in your logs. Optional, not a substitute for the above.
3. Firewall: Default Deny
sudo apt install ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH # or: sudo ufw allow 2222/tcp if you changed the port
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
UFW (and firewalld on RHEL-based distros) both drive nftables under the hood now — the old iptables chains are a legacy compatibility layer, not your primary interface anymore. For anything beyond simple port rules, nft directly gives finer control, but UFW covers the vast majority of single-VPS setups.
4. Automatic Security Patching
The gap between a CVE disclosure and your patch is where breaches happen — the 2024 OpenSSH "regreSSHion" RCE (CVE-2024-6387) was weaponized within days. Close that gap automatically:
sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades
In /etc/apt/apt.conf.d/50unattended-upgrades, stick to the security pocket, blacklist anything you'd rather patch by hand, and schedule reboots for a known-quiet window:
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::Package-Blacklist {
"postgresql-*";
"linux-image-*";
};
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
Unattended-Upgrade::Mail "you@yourdomain.com";
Unattended-Upgrade::MailReport "on-change";
Confirm the -security pocket is active in /etc/apt/apt.conf.d/20auto-upgrades, and dry-run after any edit: sudo unattended-upgrade --dry-run -v. For zero-downtime kernel patching, Canonical's Livepatch service is free for up to 5 machines.
5. Backups — Set Up Before You Need Them
Hardening reduces the odds of compromise; it doesn't make you immune, and it does nothing for hardware failure or a bad rm -rf. Follow the 3-2-1 rule: 3 copies of your data, on 2 different media, with 1 off-site.
# restic example — encrypted, deduplicated, incremental
restic init --repo s3:s3.amazonaws.com/your-backup-bucket
restic backup /etc /home /var/www --repo s3:s3.amazonaws.com/your-backup-bucket
Automate it with a systemd timer or cron, and put a restore drill on your calendar now — a backup you haven't test-restored isn't a backup.
Tier 2 — Within the First Week
6. Intrusion Banning: Fail2Ban (+ CrowdSec if you're scaling)
Fail2Ban — simple, ~15 MB RAM, the right default for one server:
sudo apt install fail2ban
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
Edit /etc/fail2ban/jail.local:
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 3
backend = systemd
banaction = nftables-multiport
[sshd]
enabled = true
port = 2222
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
CrowdSec is worth adding once you're running more than one box, or want protection against attackers before they've hit your logs — it shares crowd-sourced IP reputation across its network via lightweight "bouncers" that write directly into nftables sets. Common pattern: Fail2Ban for SSH, CrowdSec for the web-facing layer (its Nginx/Caddy bouncer integrations are solid). They coexist fine as long as they're not both managing the exact same log source.
7. Kernel Network Hardening
Create /etc/sysctl.d/99-hardening.conf:
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv6.conf.all.accept_redirects = 0
kernel.dmesg_restrict = 1
sudo sysctl --system
8. File Permissions & Mandatory Access Control
# Sane default umask for new files
echo "umask 027" | sudo tee -a /etc/profile.d/umask.sh
# Find unexpected SUID/SGID binaries — investigate anything you don't recognize
sudo find / -xdev -perm -4000 -o -perm -2000 2>/dev/null
# Confirm AppArmor is enforcing (Ubuntu/Debian default MAC)
sudo aa-status
On RHEL-based systems, use sestatus and keep SELinux in Enforcing mode rather than switching to Permissive to make an install easier — fix the policy instead.
9. Full-Disk Encryption (LUKS)
Protects data at rest if a drive is stolen, misconfigured, or decommissioned by a third party — relevant on any VPS since the underlying storage is managed by your provider, not you. It's best enabled during the OS install; encrypting an existing root filesystem in place is disruptive and rarely worth it. For existing servers, focus on encrypting a dedicated data or backup partition instead — and note that restic/borgbackup already encrypt archives at rest, so your Tier 1 backups are covered either way.
10. Database Hardening
If you're running MySQL/MariaDB or PostgreSQL on the same box, don't expose it publicly and don't use superuser accounts for application connections:
# MySQL/MariaDB — /etc/mysql/mariadb.conf.d/50-server.cnf
[mysqld]
bind-address = 127.0.0.1
# PostgreSQL — postgresql.conf
listen_addresses = 'localhost'
-- Least-privilege app user, MySQL example
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'strong-random-password';
GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'app_user'@'localhost';
FLUSH PRIVILEGES;
Run mysql_secure_installation (or the PostgreSQL equivalent of removing default/trust auth) to kill anonymous accounts and remote root. If a second server genuinely needs DB access, restrict it by source IP in your firewall rather than binding to 0.0.0.0.
Tier 3 — If It Applies to Your Stack
11. TLS: Get an A+ Without Guessing
If you're terminating TLS on the box (Nginx, Caddy, Apache), use Let's Encrypt with auto-renewal and Mozilla's current Intermediate profile — TLS 1.2 + 1.3 only, no legacy DHE ciphers, every suite forward-secret and AEAD:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
sudo systemctl list-timers | grep certbot # confirm the renewal timer is active
ssl_protocols TLSv1.2 TLSv1.3;
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;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
server_tokens off;
# Basic request-rate protection against scraping/brute force at the app layer
limit_req_zone $binary_remote_addr zone=basic:10m rate=10r/s;
limit_req zone=basic burst=20 nodelay;
ssl_prefer_server_ciphers off is intentional, not an oversight — every cipher in the Intermediate list is already strong, so letting the client pick lets mobile devices without AES hardware acceleration negotiate ChaCha20 instead. Only add the HSTS preload flag if every subdomain will always serve HTTPS — removal from browser preload lists takes months.
One 2026-specific note: public CA certificate lifetimes are shortening (down to 200 days as of March 2026, and heading lower over the next few years), so make sure renewal is genuinely automated rather than a calendar reminder — verify with sudo certbot renew --dry-run.
12. SSH at Scale: Certificates and TOTP 2FA
A single-admin box is fine with one hardware or software key. Once more than one or two people need access, static keys in authorized_keys become an operational liability — nobody remembers to remove them when someone leaves.
SSH certificates replace that with short-lived, centrally-issued credentials:
# One-time: create a CA keypair, keep the private half offline
ssh-keygen -t ed25519 -f ssh-user-ca -C "internal CA"
# Sign a user's key for 24 hours, scoped to a principal
ssh-keygen -s ssh-user-ca -I alice-daily -n alice,ops -V +24h alice_id_ed25519.pub
# /etc/ssh/sshd_config
TrustedUserCAKeys /etc/ssh/ca/trusted_user_ca.pub
Revoking access is then just "stop issuing certificates for that principal" instead of hunting down every server's authorized_keys file.
TOTP 2FA is the lower-cost option if hardware keys aren't practical for your team:
sudo apt install libpam-google-authenticator
google-authenticator # run as the user; save the printed backup codes
# /etc/pam.d/sshd — replace the @include common-auth line
auth required pam_google_authenticator.so
# sshd_config
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
This forces both a valid key and a TOTP code. It's not as phishing-resistant as FIDO2, but it's a meaningful step up from key-only auth for teams that don't want to distribute hardware tokens.
Going portless entirely is worth a look once you're managing more than a couple of servers: Tailscale SSH authenticates sessions over an already-authenticated WireGuard mesh with no inbound port required at all, and AWS SSM Session Manager / GCP IAP TCP forwarding offer the same idea gated by cloud IAM instead of network exposure.
13. Docker Container Security
Run containers as a non-root user (
USERdirective in the Dockerfile), never--privileged.Don't mount
/var/run/docker.sockinto a container unless that container's entire job is orchestration — it's root-equivalent access to the host.Set
read_only: trueand mount an explicittmpfsfor anything that needs to write.In
/etc/docker/daemon.json, disable inter-container communication you don't need and consideruserns-remapso container root maps to an unprivileged host UID:{ "icc": false, "userns-remap": "default", "no-new-privileges": true }Keep base images current; rebuild on a schedule so you're not shipping stale CVEs, and scan with
docker scoutor Trivy before deploying.Fail2Ban doesn't play well with Docker's own iptables/nftables management — most people run it on the host watching host logs rather than trying to containerize it.
14. Systemd Sandboxing for Non-Containerized Services
If an app runs directly as a systemd unit rather than in a container, you can get most of the isolation Docker would give you for free:
[Service]
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/myapp
MemoryMax=768M
CapabilityBoundingSet=
This limits blast radius — a compromised or buggy service can't read arbitrary paths, escalate privileges, or take down the host by exhausting memory. Check what a unit is actually doing with systemd-analyze security <unit>, which scores its exposure and flags missing sandboxing directives.
Tier 4 — Ongoing, Forever
15. Logging, Auditing, and Rootkit Checks
sudo apt install auditd lynis rkhunter
# Watch the files attackers actually touch
sudo tee -a /etc/audit/rules.d/audit.rules <<'EOF'
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k sudoers
-w /etc/ssh/sshd_config -p wa -k sshd_config
-w /root/.ssh/authorized_keys -p wa -k ssh_keys
EOF
sudo systemctl restart auditd
# Full posture audit
sudo lynis audit system
# Rootkit scan
sudo rkhunter --check
Antivirus isn't a meaningful control on a Linux server — proper hardening, patching, and log review outperform it. Skip it and spend the time here instead.
16. Maintenance Cadence
Configuration drifts. Put this on a recurring calendar block:
Weekly — review Fail2Ban/CrowdSec ban logs, confirm unattended-upgrades ran cleanly, check
df -hand disk usage trends.Monthly — audit user accounts and
sudoers, reviewss -tlnpfor services listening that shouldn't be, runlynis audit systemand act on new warnings.Quarterly — rotate SSH keys/certificates and any long-lived credentials, re-run
ssh-auditand a TLS scan (SSL Labs ortestssl.sh), test a full backup restore.
Quick Checklist
Tier 1 — before production traffic:
Root login disabled, sudo user created, key-based SSH only
sshd_confighardened: modern KEX/ciphers,MaxAuthTries 3, no forwardingFirewall default-deny, only required ports open
unattended-upgradesinstalled, security pocket only, tested with--dry-runAutomated, encrypted, off-site backups — restore-tested
Tier 2 — first week:
Fail2Ban (or CrowdSec) active and tested
Kernel sysctl hardening applied
AppArmor/SELinux enforcing, sane umask, SUID audit done
Full-disk or backup-archive encryption confirmed
Database bound to localhost, least-privilege app user
Tier 3 — if applicable:
TLS on Mozilla Intermediate profile, auto-renewing certs, HSTS enabled
SSH certificates or TOTP 2FA in place for multi-admin access
Docker containers non-root, no exposed socket, images kept current
systemd sandboxing applied to bare-metal services
Tier 4 — recurring:
auditd watching critical files, Lynis/rkhunter scans scheduled
Weekly/monthly/quarterly maintenance cadence on the calendar
Run Tier 1 on every new server before it touches production traffic. The rest isn't optional so much as scheduled — security is a habit you keep paying into, not a setup you finish.
Comments (0)
Login to post a comment.