Most compromised WordPress sites are not broken through WordPress core, but through outdated plugins, weak or reused admin passwords and PHP files uploaded where they should never run. In this tutorial you will harden a WordPress site running on Nginx and PHP-FPM on Ubuntu 24.04: you will set up updates, fix file ownership and permissions, lock down wp-config.php, block dangerous paths in Nginx, rate limit and ban brute-force login attempts, add two-factor authentication, and check your files for tampering.

Prerequisites

To follow this tutorial, you will need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has sudo privileges and UFW enabled.
  • WordPress running on Nginx and PHP 8.3-FPM, installed in /var/www/your_domain and served over HTTPS at https://your_domain.
  • WP-CLI installed as /usr/local/bin/wp. The WP-CLI commands below run as www-data from /var/www/your_domain.
  • A recent backup of the database and files, so you can roll back any change.

Replace your_domain with your own domain throughout.

Step 1 - Updating WordPress, plugins and themes

Known vulnerabilities in plugins are the most common way into a WordPress site, so updates come first. Check what is outdated:

cd /var/www/your_domain
sudo -u www-data wp core check-update
sudo -u www-data wp plugin list --update=available
sudo -u www-data wp theme list --update=available

Update everything:

sudo -u www-data wp core update
sudo -u www-data wp plugin update --all
sudo -u www-data wp theme update --all

Remove plugins and themes you do not use. Deactivated code can still be reached directly over HTTP and exploited:

sudo -u www-data wp plugin list --status=inactive
sudo -u www-data wp plugin delete plugin-name

Keep one default theme, such as Twenty Twenty-Five, as a fallback and delete the other inactive themes.

WordPress installs minor core releases (security fixes) automatically. To also let plugins update themselves, enable auto-updates for all of them:

sudo -u www-data wp plugin auto-updates enable --all

Verify:

sudo -u www-data wp plugin auto-updates status --all

Step 2 - Setting file ownership and permissions

WordPress needs to write to wp-content for uploads and updates, but nothing should be writable by other users on the server. Give the files to www-data and apply standard permissions: 755 for directories and 644 for files:

sudo chown -R www-data:www-data /var/www/your_domain
sudo find /var/www/your_domain -type d -exec chmod 755 {} +
sudo find /var/www/your_domain -type f -exec chmod 644 {} +

wp-config.php contains the database password and secret keys, so only its owner and group should read it:

sudo chmod 640 /var/www/your_domain/wp-config.php

Check for anything that is still world-writable. This command should print nothing:

sudo find /var/www/your_domain -perm -o+w

Step 3 - Hardening wp-config.php

A few constants in wp-config.php remove features attackers rely on. Open the file:

sudo nano /var/www/your_domain/wp-config.php

Add these lines above the comment /* That's all, stop editing! Happy publishing. */:

define( 'DISALLOW_FILE_EDIT', true );
define( 'FORCE_SSL_ADMIN', true );
define( 'WP_DEBUG', false );

DISALLOW_FILE_EDIT removes the theme and plugin file editors from the dashboard. Without it, anyone who steals an admin session can write PHP code into your site in a few clicks. If WP_DEBUG is already defined in the file, change its value instead of adding a second definition.

Replace the secret keys and salts. This logs out every user, including any attacker holding a stolen session cookie:

sudo -u www-data wp config shuffle-salts
Success: Shuffled the salt keys.

Check that PHP still loads the configuration without errors:

sudo -u www-data wp config list --fields=name --format=csv | grep -E 'DISALLOW_FILE_EDIT|FORCE_SSL_ADMIN'
DISALLOW_FILE_EDIT
FORCE_SSL_ADMIN

Step 4 - Securing administrator accounts

List the accounts with the administrator role:

sudo -u www-data wp user list --role=administrator --fields=ID,user_login,user_email

Remove any administrator you do not recognize, and lower the role of users who do not need full rights (editors and authors can publish content without installing plugins). If an account is named admin, create a new administrator with a different name, then delete the old one and give its content to the new account:

sudo -u www-data wp user create your_admin_user you@your_domain --role=administrator --prompt=user_pass
sudo -u www-data wp user delete admin --reassign=your_admin_user

With --prompt=user_pass, WP-CLI asks for the password instead of leaving it in your shell history. Use a long, unique password from a password manager.

Add two-factor authentication with the Two Factor plugin, maintained by WordPress contributors:

sudo -u www-data wp plugin install two-factor --activate

Each user then enables it in Users > Profile > Two-Factor Options, where they can pair an authenticator app (TOTP) and print backup codes. Enable it for every administrator account.

Step 5 - Blocking dangerous paths in Nginx

Several requests have no legitimate use on a typical site: executing PHP inside uploads, reading hidden files such as .git or .env, and the XML-RPC endpoint, which is a favorite for brute-force attacks because it accepts many password guesses per request. Open your site's server block:

sudo nano /etc/nginx/sites-available/your_domain

Add these rules inside the server block that listens on port 443, above your location ~ \.php$ block. Nginx uses the first matching regular expression location, so these must come first:

location ~* ^/wp-content/uploads/.*\.(php|phtml|phar)$ {
    deny all;
}

location = /xmlrpc.php {
    deny all;
    access_log off;
    log_not_found off;
}

location ~ /\.(?!well-known) {
    deny all;
}

location ~* ^/(wp-config\.php|readme\.html|license\.txt)$ {
    deny all;
}

Add basic security headers in the same server block:

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000" always;

Only add Strict-Transport-Security once HTTPS works for the domain; browsers will refuse plain HTTP for a year afterwards. Test and reload Nginx:

sudo nginx -t
sudo systemctl reload nginx

Verify that PHP in uploads is blocked and XML-RPC is closed:

curl -s -o /dev/null -w '%{http_code}\n' https://your_domain/wp-content/uploads/test.php
curl -s -o /dev/null -w '%{http_code}\n' https://your_domain/xmlrpc.php
curl -sI https://your_domain/ | grep -i x-content-type-options
403
403
x-content-type-options: nosniff

Step 6 - Rate limiting the login page

Nginx can limit how often a single IP address posts to wp-login.php, which slows down password guessing before it reaches PHP. Define a shared memory zone in the http context:

sudo nano /etc/nginx/conf.d/wp-login-limit.conf
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=6r/m;

In your site's 443 server block, add a location for the login page, above the general PHP location, that applies the limit and passes the request to PHP:

location = /wp-login.php {
    limit_req zone=wplogin burst=5 nodelay;
    limit_req_status 429;
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}

This allows a burst of 5 requests and then one every 10 seconds per IP. Reload and test by sending 10 quick requests:

sudo nginx -t && sudo systemctl reload nginx
for i in $(seq 1 10); do curl -s -o /dev/null -w '%{http_code} ' https://your_domain/wp-login.php; done; echo
200 200 200 200 200 200 429 429 429 429

Step 7 - Banning brute-force attackers with Fail2ban

Rate limiting slows attackers down; Fail2ban blocks them at the firewall after repeated attempts. Install it:

sudo apt install fail2ban

Create a filter that matches login form submissions and XML-RPC calls in the Nginx access log:

sudo nano /etc/fail2ban/filter.d/wordpress-login.conf
[Definition]
failregex = ^<HOST> .* "POST /(wp-login\.php|xmlrpc\.php)
ignoreregex =

A successful login also sends a POST, but a real user does not log in more than five times in ten minutes. Create the jail:

sudo nano /etc/fail2ban/jail.d/wordpress.conf
[wordpress-login]
enabled  = true
port     = http,https
filter   = wordpress-login
logpath  = /var/log/nginx/access.log
backend  = auto
maxretry = 5
findtime = 10m
bantime  = 1h

Test the filter against your existing log, then restart Fail2ban:

sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-login.conf
sudo systemctl restart fail2ban
sudo fail2ban-client status wordpress-login
Status for the jail: wordpress-login
|- Filter
|  |- Currently failed: 0
|  |- Total failed:     0
|  `- File list:        /var/log/nginx/access.log
`- Actions
   |- Currently banned: 0
   |- Total banned:     0
   `- Banned IP list:

If you lock yourself out, unban your address with sudo fail2ban-client set wordpress-login unbanip your_ip.

Step 8 - Checking files for tampering

WP-CLI can compare core and plugin files against the checksums published on WordPress.org. Any modified or extra file in core is a strong sign of compromise:

sudo -u www-data wp core verify-checksums
sudo -u www-data wp plugin verify-checksums --all
Success: WordPress installation verifies against checksums.
Success: Verified 8 of 8 plugins.

Premium plugins that are not on WordPress.org are reported as unverifiable, which is expected. Also look for PHP files in uploads, where there should be none:

sudo find /var/www/your_domain/wp-content/uploads -name '*.php'

Run these checks weekly, or after any suspicious activity. For continuous malware scanning and a WordPress-aware firewall, a plugin such as Wordfence adds a scanner and login alerts on top of the server-level measures in this guide.

Step 9 - Backing up the site

Hardening reduces the risk but does not remove it; a recent, off-server backup is what lets you recover. At minimum, export the database and archive the files regularly, and copy both to storage outside the server:

sudo -u www-data wp db export - | gzip > ~/db-$(date +%F).sql.gz
sudo tar -czf ~/files-$(date +%F).tar.gz -C /var/www your_domain

Automate this with a scheduled script or a backup plugin, and test a restore on a separate server from time to time.

Troubleshooting

  • Updates ask for FTP credentials: WordPress cannot write to its own files. Run the chown command from Step 2 again.
  • Images or media return 403 after Step 5: the uploads rule is catching more than PHP files. Make sure the regular expression ends in \.(php|phtml|phar)$.
  • Login page shows "429 Too Many Requests" for real users: raise rate or burst in the limit_req settings, for example rate=10r/m.
  • Fail2ban never bans: check that sudo fail2ban-regex reports matches, and that the jail's logpath is the log your site actually writes to if you use a per-site access log.

Conclusion

Your WordPress site now runs up-to-date code with correct permissions, a locked-down configuration, two-factor authentication for administrators, and Nginx and Fail2ban rules that stop the most common automated attacks. Next, schedule automated off-server backups, review your active plugins every few months, and add a Content Security Policy once you know which external scripts your theme loads.