WordPress runs roughly 43% of all websites, which makes it the single biggest target for automated attacks. Managed hosting providers handle the security baseline for us, patching the server, blocking brute force, scanning files. On a VPS that responsibility shifts entirely to the administrator. We get root access and full flexibility, and we also get full ownership of every misconfiguration that lands the site in someone else's botnet.
In this guide we walk through hardening a WordPress site on a fresh VPS step by step. We cover the SSH layer, firewall, HTTPS, file permissions, wp-config tweaks, brute force protection, and backups. The goal is a baseline that stops the vast majority of automated attacks that hit every public WordPress site within hours of going live.
What we are protecting against
The threat model for a typical WordPress site on a VPS breaks down into a few recurring categories. The first is brute force on the login endpoints, mostly wp-login.php and xmlrpc.php, where bots cycle through credential lists at hundreds of attempts per minute. The second is exploitation of vulnerable plugins and themes, which according to multiple security vendors account for over 90% of WordPress security incidents. The third is direct SSH brute force against the VPS itself. The fourth is misconfigured file permissions that let attackers read database credentials or write malicious code. The fifth is missing TLS, which leaks admin sessions over open networks.
Each section below addresses one of these vectors with a concrete configuration step.
Prerequisites
To follow along we need:
- A VPS running Ubuntu 22.04 LTS or Ubuntu 24.04 LTS
- Root SSH access provided by the hosting panel
- A working WordPress installation on a LEMP stack (Linux, Nginx, MySQL or MariaDB, PHP-FPM)
- A registered domain with an A-record pointing to the VPS public IP
For our walkthrough we use a Serverspace VPS with Ubuntu 24.04 as the test environment, but every command is portable to any Ubuntu-based provider. If you need a fresh server to follow along, you can spin up a Serverspace VPS in a few minutes.
Step 1: Hardening the SSH layer
The SSH service is the single most exposed part of any VPS. Within hours of provisioning we will see hundreds of brute force attempts in /var/log/auth.log from automated scanners. The first move is to take SSH off password authentication.
We start by creating a non-root sudo user, since root is the one username every attacker tries first.
sudo adduser deploy
sudo usermod -aG sudo deploy
Generate an SSH key on the local machine (not on the server) and copy the public key to the VPS:
ssh-keygen -t ed25519 -C "your@email.com"
ssh-copy-id deploy@your-server-ip
Test the new login in a fresh terminal. If ssh deploy@your-server-ip works without a password prompt, we proceed.
Open /etc/ssh/sshd_config and apply the following settings:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
MaxAuthTries 3
AllowUsers deploy
Validate and reload:
sudo sshd -t
sudo systemctl reload ssh
Keep the existing SSH session open while testing the new login from a second terminal. If something is misconfigured and password auth is already disabled, the open session is the only way back in.
Step 2: Configuring the UFW firewall
A firewall enforces a deny-by-default policy at the kernel level. Anything we did not explicitly allow gets dropped. Ubuntu ships with UFW (Uncomplicated Firewall), which is a friendly front-end for iptables.
The order matters here. We allow SSH first, then enable the firewall, otherwise we lock ourselves out of the server.
sudo ufw allow OpenSSH
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 'Nginx Full'
sudo ufw enable
Verify the rules:
sudo ufw status verbose
Expected output should list OpenSSH and Nginx Full as ALLOW IN for both IPv4 and IPv6, with default policy deny incoming and allow outgoing.
This baseline opens only ports 22, 80, and 443. Database ports, PHP-FPM sockets, and anything else stays bound to localhost or behind the firewall.
Step 3: Enforcing HTTPS and security headers
Modern browsers mark HTTP-only sites as Not Secure, and admin sessions over plain HTTP can be intercepted on any open Wi-Fi. Let's Encrypt provides free certificates and certbot automates the entire process, including renewal.
Install certbot and the Nginx plugin:
sudo apt install certbot python3-certbot-nginx -y
Issue a certificate for the domain:
sudo certbot --nginx -d example.com -d www.example.com
Certbot edits the Nginx config automatically, sets up redirects from HTTP to HTTPS, and registers a cron job for renewal. Verify the renewal works without actually renewing:
sudo certbot renew --dry-run
Once HTTPS is live, we add security headers to the Nginx server block. Open /etc/nginx/sites-available/example.com and add the following inside the server block listening on port 443:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
HSTS forces browsers to use HTTPS for the next year. X-Frame-Options blocks clickjacking via iframe embedding. X-Content-Type-Options stops MIME sniffing. Referrer-Policy controls Referer header leakage. Permissions-Policy disables unused browser features.
Test the Nginx config and reload:
sudo nginx -t
sudo systemctl reload nginx
Verify with securityheaders.com by entering the domain. A clean baseline scores at least an A.
Step 4: WordPress file permissions and ownership
A surprising number of WordPress compromises trace back to permission errors. The classic anti-pattern is running chmod -R 777 wp-content to fix an upload issue. That fix turns the entire directory into a writable playground for anyone who finds an exploit.
The correct baseline: directories get 755, files get 644, and wp-config.php gets restricted further. The web server runs as www-data on Debian-based distributions:
sudo chown -R www-data:www-data /var/www/html
sudo find /var/www/html -type d -exec chmod 755 {} \;
sudo find /var/www/html -type f -exec chmod 644 {} \;
sudo chmod 640 /var/www/html/wp-config.php
We use 640 instead of 600 on wp-config.php because PHP-FPM reads the file as a member of the www-data group. With 600 and the wrong owner the site whitescreens because PHP cannot read its own config.
For uploads, www-data needs write access to /wp-content/uploads, and no other directory should be writable by the web server unless a specific plugin requires it.
Step 5: Hardening wp-config.php
The wp-config.php file is the brain of the WordPress install. A few constants here close several attack vectors at once.
First, rotate the security salts. WordPress uses eight cryptographic keys for cookie and session integrity. If they were ever copied from an example or leaked through a backup, every existing session is forgeable. Generate fresh keys at https://api.wordpress.org/secret-key/1.1/salt/ and replace the corresponding block in wp-config.php.
Add the following constants above the line that reads /* That's all, stop editing! */:
define('DISALLOW_FILE_EDIT', true);
define('DISALLOW_FILE_MODS', true);
define('FORCE_SSL_ADMIN', true);
define('WP_AUTO_UPDATE_CORE', 'minor');
A short pass through what each one does. DISALLOW_FILE_EDIT removes the built-in theme and plugin editor from the dashboard, so an attacker who steals an admin session cannot inject PHP through the UI. DISALLOW_FILE_MODS blocks plugin and theme installations from inside WordPress; updates have to go through SSH or WP-CLI. FORCE_SSL_ADMIN enforces HTTPS for admin pages. WP_AUTO_UPDATE_CORE set to 'minor' applies security patches automatically without breaking on a major release.
Step 6: Blocking brute force with Fail2Ban
Fail2Ban watches log files for patterns and bans offending IPs at the firewall level. By default it protects sshd, and we extend it to cover wp-login.php and xmlrpc.php.
Install Fail2Ban and create a local jail config (we never edit jail.conf directly, since updates overwrite it):
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
Create a custom filter for WordPress at /etc/fail2ban/filter.d/wordpress.conf:
[Definition]
failregex = ^<HOST> .* "(GET|POST) /wp-login.php
^<HOST> .* "(GET|POST) /xmlrpc.php
ignoreregex =
Append a matching jail to /etc/fail2ban/jail.local:
[wordpress]
enabled = true
port = http,https
filter = wordpress
logpath = /var/log/nginx/access.log
maxretry = 5
bantime = 86400
findtime = 600
Restart Fail2Ban and check the jail status:
sudo systemctl restart fail2ban
sudo fail2ban-client status wordpress
The output shows the file being watched, current and total failed attempts, and the list of currently banned IPs. After a few hours of public exposure the Banned IP list will start filling up.
Step 7: Disabling XML-RPC if not used
XML-RPC is a legacy WordPress endpoint that allows remote publishing through clients like the WordPress mobile app or Jetpack. Most modern sites do not use it, and it remains one of the most aggressively brute-forced endpoints on the platform.
If the site does not need XML-RPC, the cleanest solution is a server-level block. Add this inside the Nginx server block:
location = /xmlrpc.php {
deny all;
access_log off;
return 444;
}
Reload Nginx with sudo systemctl reload nginx, and any request to /xmlrpc.php gets dropped before WordPress loads. If XML-RPC is required, restrict access by IP using allow and deny directives in the same block.
Step 8: Backups and recovery
A site without tested backups is a site we have not actually secured. The 3-2-1 rule applies: three copies of the data, on two different media types, with at least one off-site.
A simple bash script handles WordPress backups well. Create /usr/local/bin/wp-backup.sh:
#!/bin/bash
DATE=$(date +%F)
BACKUP_DIR="/var/backups/wp"
mkdir -p $BACKUP_DIR
cd /var/www/html
wp db export $BACKUP_DIR/db-$DATE.sql --allow-root
tar -czf $BACKUP_DIR/files-$DATE.tar.gz wp-content
find $BACKUP_DIR -mtime +7 -delete
Make it executable and schedule it with cron:
sudo chmod +x /usr/local/bin/wp-backup.sh
sudo crontab -e
Add the line:
0 3 * * * /usr/local/bin/wp-backup.sh
The find directive keeps a rolling seven days of local copies. For off-site storage, push the backup directory to S3-compatible object storage. Serverspace offers compatible cloud storage that works with rclone or aws-cli for this purpose.
One step matters more than the rest: actually restore a backup once a quarter into a test directory. Backups that have never been tested have a way of failing on the day we need them.
Common mistakes and how to fix them
The table below covers the misconfigurations seen most often when auditing compromised WordPress installations.
| Symptom | Root cause | Solution |
|---|---|---|
| Malware injected through uploads | chmod 777 on wp-content | Reset to 755 directories, 644 files |
| Constant brute force on wp-login | No rate limiting or fail2ban filter | Deploy the wordpress jail from Step 6 |
| Database breach via SQL injection | DB user has GRANT ALL on all databases | Scope the user to the WordPress database only |
| Locked out of SSH after firewall enable | UFW activated before allowing SSH | Recover via hosting console, allow OpenSSH, then enable |
| Plugin update fails after hardening | DISALLOW_FILE_MODS prevents updates | Set to false via SFTP, update, set back to true |
| Admin loads over HTTP after migration | FORCE_SSL_ADMIN not defined | Add the constant, force HTTPS at Nginx level |
| Site whitescreens after permission change | wp-config.php set to 600 with wrong owner | Use 640 with www-data group ownership |
Conclusion
Securing a WordPress site on a VPS is a layered process. The server, the web server, the application, and the data each need their own controls. Start with SSH keys and a firewall, add HTTPS with proper headers, lock down file permissions, harden wp-config, deploy fail2ban, and back up everything off-site.
This baseline blocks the vast majority of automated attacks. Targeted attacks need more, like a WAF (ModSecurity or Cloudflare), regular security audits, and centralized log monitoring, but those build on top of what we covered here.