How to harden a WordPress site on your own server (Ubuntu or Debian)

Most WordPress break-ins start with an old plugin or a stolen password. These steps make sure that, if it happens, the attacker can do as little as possible. Each one comes with a test, so you can see it working.

New to running WordPress yourself? Start with our overview of running WordPress on your own server. This guide goes deeper into the security steps, with the exact settings for Nginx and Apache.

Before you start

  • Take a backup of the files and the database. See our backup guide.
  • The examples use a site called shop.example. It runs as the Linux user shop, and WordPress is in /var/www/shop.example/public. Use your own names.
  • The PHP version in paths is 8.3 on Ubuntu 24.04 and 8.4 on Debian 13. Check yours with php -v.
  • Commands that start with wp use WP-CLI. Run them in the WordPress folder, as the site's user, never as root:
    cd /var/www/shop.example/public
    sudo -u shop -- wp core version

1. Let the site's own user own the files

PHP should run as the site's own user, and that user should own the WordPress files. Then WordPress can update itself, and a break-in stays inside this one site. If you haven't set this up yet, follow our guide to one user and PHP-FPM pool per site first. Check which user PHP runs as:

grep -E '^(user|group) ' /etc/php/8.3/fpm/pool.d/shop.conf

It should say shop twice. Now give the files to that user, with normal permissions:

sudo chown -R shop:shop /var/www/shop.example/public
sudo find /var/www/shop.example/public -type d -exec chmod 755 {} +
sudo find /var/www/shop.example/public -type f -exec chmod 644 {} +
sudo chmod 600 /var/www/shop.example/public/wp-config.php

wp-config.php holds your database password. With 600, only shop can read it. That's enough: PHP runs as shop, and Nginx or Apache never need to read this file. Check that the web server can't:

sudo -u www-data cat /var/www/shop.example/public/wp-config.php

You should see Permission denied. Use 640 only if another user in the shop group really needs to read it, such as a backup job. Keep in mind that the pool guide adds www-data to that group too.

Does WordPress ask for FTP details when you update? Then PHP doesn't own the files. In our tests, as soon as the files belonged to root, WordPress stopped updating itself and asked for an FTP login instead.

2. Turn off the file editor in the dashboard

WordPress lets admins change plugin and theme code in the browser. With a stolen admin password, that's an easy way to add bad code. Turn it off:

sudo -u shop -- wp config set DISALLOW_FILE_EDIT true --raw

This adds define( 'DISALLOW_FILE_EDIT', true ); to wp-config.php and keeps its 600 permission. The "Theme File Editor" and "Plugin File Editor" menus disappear. If you open /wp-admin/plugin-editor.php directly, WordPress says "Sorry, you are not allowed to access this page."

There is a stronger setting, DISALLOW_FILE_MODS. It also blocks installing and updating plugins from the dashboard, and it turns off automatic updates too. Only use it if you do all updates yourself with WP-CLI.

3. Stop PHP from running in the uploads folder

The uploads folder should only hold images and documents. But PHP can write there, so a weak plugin may let an attacker upload a .php file and then open it in the browser. Block that in the web server.

Nginx: in the site's file in /etc/nginx/sites-available/, add this above your location ~ \.php$ block. Nginx uses the first regex rule that matches, so the order matters:

location ~* ^/wp-content/uploads/.*\.php$ {
    deny all;
}
sudo nginx -t && sudo systemctl reload nginx

Apache: add this inside the site's <VirtualHost> in /etc/apache2/sites-available/. It blocks .phar and .phtml too: Apache's PHP setup on Ubuntu and Debian runs those as well, and in our test a .phar file in uploads ran.

<Directory /var/www/shop.example/public/wp-content/uploads>
    <FilesMatch "\.ph(ar|p|tml)$">
        Require all denied
    </FilesMatch>
</Directory>
sudo apache2ctl configtest && sudo systemctl reload apache2

Put it in the site's config, not in a .htaccess file inside uploads. PHP can write to uploads, so it could also delete that .htaccess.

Test it. Make a harmless PHP file in uploads, open it, then delete it:

echo '<?php echo "PHP RUNS";' | sudo -u shop tee /var/www/shop.example/public/wp-content/uploads/test-x7.php
curl -i https://shop.example/wp-content/uploads/test-x7.php
sudo rm /var/www/shop.example/public/wp-content/uploads/test-x7.php

You want 403 Forbidden. If you see PHP RUNS, the rule isn't working. On Nginx, it's usually below the PHP block. Images in uploads still load as before.

4. Block or limit xmlrpc.php

xmlrpc.php is an old way for apps to talk to WordPress. Bots use it to guess passwords, many at a time. Most sites don't need it. Check first: Jetpack and some older apps and plugins still use it.

Nginx (in the site's server block; the place doesn't matter, because an exact = match always wins):

location = /xmlrpc.php {
    deny all;
}

Apache (inside the <VirtualHost>):

<Files xmlrpc.php>
    Require all denied
</Files>

Reload the web server as in step 3, then test. You want 403:

curl -s -o /dev/null -w '%{http_code}\n' -X POST https://shop.example/xmlrpc.php

Need it for one service? Allow only its address. Replace 203.0.113.10 with the real one. On Nginx, this block must also pass the request to PHP, or allowed visitors get 405 Not Allowed. Use the same socket as your main PHP block:

location = /xmlrpc.php {
    allow 203.0.113.10;
    deny all;
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/shop.sock;
}

On Apache, swap Require all denied for Require ip 203.0.113.10.

5. Keep WordPress, plugins and themes updated

Automatic updates. WordPress installs its own security and fix releases (such as 6.7.10 to 6.7.11) by itself. That's on by default. Plugins and themes don't update by themselves until you turn it on:

sudo -u shop -- wp plugin auto-updates enable --all
sudo -u shop -- wp theme auto-updates enable --all
sudo -u shop -- wp plugin list --fields=name,version,auto_update

The checks run from WordPress's own scheduler, WP-Cron. For automatic updates to work:

  • PHP must own the files (step 1).
  • DISALLOW_FILE_MODS must not be set (step 2).
  • WP-Cron must run. On a quiet site, use a real cron job.

Automatic updates have a downside: a plugin update can break the site while nobody is watching. For a shop or another important site, update by hand after a backup, or test on a copy first.

Updating by hand. Back up the database, see what's waiting, then update:

sudo -u shop -- wp db export /var/www/shop.example/before-update.sql
sudo -u shop -- wp core check-update
sudo -u shop -- wp core update --minor
sudo -u shop -- wp core update-db
sudo -u shop -- wp plugin update --all
sudo -u shop -- wp theme update --all

Keep the database export out of the public folder, as above. --minor installs only the fix releases for your version. Without it, wp core update jumps to the newest major version: in our test it went straight from 6.7 to 7.1. That's fine once you've checked that your theme and plugins support it.

6. Check for changed files

WP-CLI can compare your files with the official copies:

sudo -u shop -- wp core verify-checksums
sudo -u shop -- wp plugin verify-checksums --all

Good output says Success. A warning such as File doesn't verify against checksum means a WordPress file was changed, and File should not exist means someone added one. Both need a closer look.

  • Only plugins from WordPress.org can be checked. Paid plugins and your own themes aren't covered.
  • Hello Dolly fails the check. The copy that comes with WordPress isn't the same as the one on WordPress.org. If you don't use it, delete it (step 7).

To put back the original WordPress files, download them again over the old ones. Use your version number from wp core version. --skip-content leaves wp-content alone:

sudo -u shop -- wp core download --force --skip-content --version=6.7.11

This doesn't remove added files. Delete those after you've looked at them. If you find signs of a break-in, follow our first steps for a hacked site: keep a copy, restore a clean backup and change all passwords. Bad code is often hidden in wp-content, and this check doesn't cover that folder. A nightly ClamAV scan covers it for known malware.

7. Delete plugins and themes you don't use

A plugin that's switched off is still on your server, and its holes can still be used. List the inactive ones:

sudo -u shop -- wp plugin list --status=inactive --field=name

Delete the ones you don't need by name, for example:

sudo -u shop -- wp plugin delete akismet hello

Do the same for themes:

sudo -u shop -- wp theme list --fields=name,status
sudo -u shop -- wp theme delete twentytwentythree

Keep the active theme, its parent theme if it's a child theme, and one default theme such as Twenty Twenty-Five. WordPress can switch to it if the active theme goes missing. WP-CLI won't delete the active theme.

8. Slow down password guessing on wp-login.php

Use long, unique passwords and two-factor login for every admin. Then let Fail2ban block addresses that keep guessing. Set it up with our guide first. Fail2ban has no ready-made rule for WordPress, but a small one is enough. WordPress answers a wrong password with status 200 and a correct one with a redirect, so the rule counts only the 200 answers:

sudo nano /etc/fail2ban/filter.d/wordpress-login.conf
[Definition]
failregex = ^<HOST> .* "POST /wp-login\.php[^"]*" 200
sudo nano /etc/fail2ban/jail.d/wordpress.local
[wordpress-login]
enabled  = true
port     = http,https
filter   = wordpress-login
logpath  = /var/log/nginx/access.log
backend  = auto
maxretry = 5
findtime = 10m
bantime  = 1h

On Apache, use logpath = /var/log/apache2/access.log. If your site has its own log file, use that one. Test the rule on your log, then turn it on:

sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-login.conf
sudo fail2ban-client -t
sudo systemctl restart fail2ban
sudo fail2ban-client status wordpress-login

The first command shows how many failed logins it found in the log. Behind Cloudflare or another proxy, this won't work, because the server sees the proxy's address, not the visitor's. On Nginx, you can also limit login tries with rate limiting, which works behind a proxy too.

If something goes wrong

  • WordPress asks for FTP details: PHP doesn't own the files. Run the chown from step 1 and check the pool's user.
  • A blank page or "500" after step 1: PHP may not be able to read wp-config.php. Check that it belongs to shop with ls -l wp-config.php, and look in the web server's error log.
  • The PHP test file in uploads still runs on Nginx: the uploads rule is below location ~ \.php$. Move it up and reload.
  • Jetpack or an app stopped working: it needs xmlrpc.php. Allow only its address (step 4) or remove the block.
  • The allowed address gets "405 Not Allowed" on xmlrpc.php: the Nginx block is missing the two lines that pass it to PHP (step 4).
  • The site breaks after an update: deactivate the plugin with sudo -u shop -- wp plugin deactivate plugin-name, or restore the backup from step 5.

Official sources

Next steps: make sure bots can't download .env, .git or backup files, and work through the security checklist for a new PHP server.

ServerNest is in development. One of our goals is to set up each WordPress site like this for you: its own user and PHP-FPM pool, and updates you can check and undo. Join early access to try it first.

← Back to the security blog