Tested on: WordPress 6.5.x on Ubuntu 24.04 LTS, Nginx 1.26.x, PHP-FPM 8.3, MariaDB 10.11. Most settings translate directly to Apache; Nginx-specific blocks are clearly marked.

Why this matters

WordPress powers a large share of the web, which makes it the most attacked CMS. Most WordPress incidents are not novel — they are one of:

  1. An out-of-date plugin with a known CVE, exploited within days of public disclosure.
  2. wp-login.php brute-force, succeeding because an admin reused a password leaked from elsewhere.
  3. Filesystem permissions that let the web server write to source files, so a single PHP RCE becomes persistent.
  4. A backup containing the entire database, stored unencrypted, exposed via a misconfigured S3 bucket.

For an agency hosting multiple client sites, the blast radius compounds: one compromised site can pivot to siblings via shared filesystem, shared database server, or shared PHP-FPM pool. This guide gives you a baseline that addresses each of the above and isolates per-site risk.

1. Per-site Unix user and filesystem perms

Each client site gets its own Unix user, its own PHP-FPM pool, and its own document root. No shared www-data ownership across sites.

1sudo adduser --system --group --home /srv/sites/example.com --shell /usr/sbin/nologin example
2sudo mkdir -p /srv/sites/example.com/{public,logs}
3sudo chown -R example:example /srv/sites/example.com
4sudo chmod 750 /srv/sites/example.com

Within the document root, the rule is: PHP-FPM (running as the site user) needs read on every file and write on a small, explicit set of paths:

 1# Source code: readable by site user, not writable.
 2find /srv/sites/example.com/public -type d -exec chmod 755 {} \;
 3find /srv/sites/example.com/public -type f -exec chmod 644 {} \;
 4
 5# wp-config.php: secret material.
 6chmod 640 /srv/sites/example.com/public/wp-config.php
 7chown example:nginx /srv/sites/example.com/public/wp-config.php
 8
 9# Writable paths: uploads, cache.
10chmod -R 755 /srv/sites/example.com/public/wp-content/uploads

WordPress core, plugins, and themes should not be web-writable in production. Updates happen via the update workflow in section 8, not by giving the web server write access to source files.

2. PHP-FPM pool per site

/etc/php/8.3/fpm/pool.d/example.com.conf:

 1[example.com]
 2user = example
 3group = example
 4listen = /run/php/example.com.sock
 5listen.owner = nginx
 6listen.group = nginx
 7listen.mode = 0660
 8
 9pm = ondemand
10pm.max_children = 16
11pm.process_idle_timeout = 30s
12pm.max_requests = 500
13
14php_admin_value[open_basedir] = /srv/sites/example.com/public:/tmp:/var/lib/php/sessions
15php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,parse_ini_file,show_source
16php_admin_value[expose_php] = Off
17php_admin_value[session.save_path] = /var/lib/php/sessions
18php_admin_flag[allow_url_fopen] = Off
19php_admin_flag[allow_url_include] = Off
20php_admin_value[upload_max_filesize] = 16M
21php_admin_value[post_max_size] = 16M

A separate pool per site means a runaway PHP process in one site cannot starve every other site of FPM workers. open_basedir keeps a compromised site from reading sibling sites’ files.

3. Nginx — restrictions specific to WordPress

The TLS configuration is the standard Nginx TLS baseline. The WordPress-specific locations sit inside that server block:

 1# Block direct access to PHP files in upload directories — a common
 2# vector for exploiting plugin upload bugs.
 3location ~* /(?:uploads|files)/.*\.php$ {
 4    deny all;
 5}
 6
 7# Block access to sensitive files.
 8location ~ /\.(?!well-known) { deny all; }
 9location ~* (?:wp-config\.php|readme\.html|license\.txt|xmlrpc\.php)$ {
10    deny all;
11}
12
13# WordPress permalinks.
14location / {
15    try_files $uri $uri/ /index.php?$args;
16}
17
18# PHP handler.
19location ~ \.php$ {
20    include fastcgi_params;
21    fastcgi_split_path_info ^(.+\.php)(/.+)$;
22    fastcgi_pass unix:/run/php/example.com.sock;
23    fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
24    fastcgi_param  HTTPS on;
25    fastcgi_read_timeout 60s;
26}
27
28# Cap admin endpoints — see /guides/nginx-ratelimit/ for the zone def.
29location = /wp-login.php {
30    limit_req zone=wplogin burst=5 nodelay;
31    include    fastcgi_params;
32    fastcgi_pass unix:/run/php/example.com.sock;
33    fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
34}

xmlrpc.php is blocked because the legitimate use cases (Jetpack, the WordPress mobile app) are vastly outweighed by its use as an authentication-brute-force amplifier. If a client genuinely needs it, restrict by IP allowlist rather than leaving it open.

4. wp-config.php flags that matter

 1// Force HTTPS for the admin and login.
 2define( 'FORCE_SSL_ADMIN', true );
 3
 4// Disable the in-dashboard theme/plugin/file editor.
 5// One compromised admin account becomes one compromised admin
 6// account, not a remote code execution primitive.
 7define( 'DISALLOW_FILE_EDIT', true );
 8
 9// Disable in-dashboard plugin/theme installs and updates.
10// Updates happen via the workflow in section 8.
11define( 'DISALLOW_FILE_MODS', true );
12
13// Direct filesystem access — required when web user cannot write
14// source files (which is correct, per section 1).
15define( 'FS_METHOD', 'direct' );
16
17// Disable XML-RPC at the application layer too.
18add_filter( 'xmlrpc_enabled', '__return_false' );
19
20// Move uploads outside the web root if the application can tolerate it.
21// (Optional — adds significant value if the site does not need
22// hot-linkable media.)
23
24// Salts: regenerate from https://api.wordpress.org/secret-key/1.1/salt/
25// once at install time and rotate after any suspected compromise.

These flags must be applied before the site goes into general production use — DISALLOW_FILE_MODS after the fact means a deployment workflow change.

5. Database user least privilege

WordPress’s own setup wizard happily grants ALL PRIVILEGES on the database. Trim it:

1CREATE USER 'example_wp'@'localhost' IDENTIFIED BY 'GENERATED_RANDOM';
2GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER,
3      CREATE TEMPORARY TABLES, LOCK TABLES
4  ON example_wp.* TO 'example_wp'@'localhost';
5FLUSH PRIVILEGES;

CREATE / DROP / ALTER are required because WordPress core and many plugins create tables on activation. SUPER, FILE, and global grants are not required and should never be granted.

For the MariaDB hardening baseline see the data-tier series; the PostgreSQL hardening guide covers the same principles for the Postgres family.

6. Admin access — passwords are not enough

  • Enforce strong passwords for every admin account.
  • Require 2FA for every admin user. WP-2FA, Two Factor Authentication (the plugin from the WordPress team), or a SAML/SSO bridge if the agency uses central identity.
  • Cap login attempts via Nginx limit_req (above) plus a plugin like Limit Login Attempts Reloaded. Belt and braces — the Nginx limit catches volume; the plugin handles per-username lockout.
  • Remove the admin username if it exists; create per-human admin accounts. Apply the same hygiene as SSH — no shared admin accounts.
  • Move /wp-admin behind an IP allowlist if the client’s admins work from known networks. Not security through obscurity — actual access control.

7. Plugin discipline

Plugins are where most WordPress sites die. Three policies, applied ruthlessly:

  1. Vetting before install. Every plugin must come from wordpress.org, have been updated within the last six months, and not be in a “closed” or “abandoned” state. Premium plugins must come from the vendor directly with a current licence.
  2. Minimum count. Every additional plugin is additional supply-chain risk. Audit quarterly; remove unused.
  3. Update cadence. Security-flagged updates apply within 24 hours. Routine updates apply within 7 days. This is a process commitment, documented and tracked.

The plugins this site is most likely to recommend across guides:

  • Wordfence or Solid Security Pro — application-layer WAF and login limiting. Pick one, not both.
  • Limit Login Attempts Reloaded — if not using a security suite.
  • WP-2FA (the maintained-by-Melapress one) — 2FA.
  • UpdraftPlus or Duplicator — backup (configured to encrypted off-site storage).

8. Update workflow without in-dashboard installs

With DISALLOW_FILE_MODS set, updates happen via wp-cli from a deploy account:

1# Core update.
2wp core update --quiet
3
4# Plugin and theme updates.
5wp plugin update --all --quiet
6wp theme  update --all --quiet
7
8# Verify core integrity afterwards.
9wp core verify-checksums

Run these via a deployment script, not interactively. Automate the “check for updates” step and require human review for major-version updates.

9. Backups — encrypted, off-site, verified

A WordPress backup is two things: the database (wp db export) and the wp-content tree. Both contain personal data; both must be encrypted at rest and in transit.

Recommended chain: wp db export and tar the content tree, piped into restic (or borgbackup) targeting an EEA-region S3-compatible endpoint. Keep the restic passphrase in a password manager separate from the server.

A backup you have not restored is not a backup. Document a quarterly restoration drill — restore to a staging environment and verify content, admin login, and a representative page load.

10. File integrity

Periodically verify that core, plugin, and theme files match their upstream checksums:

1wp core verify-checksums
2wp plugin verify-checksums --all

Schedule via cron, alert on mismatch. The most common cause of mismatch is a legitimate hotfix; the second most common is a compromise.

Gotchas

Multisite multiplies the blast radius

A WordPress multisite network with poorly isolated sub-sites is a single compromise away from being all-compromised. If multisite is in use, apply the Network Activate plugin model — sub-site admins cannot install arbitrary plugins; only super-admins can.

File permissions reset by buggy plugins

A plugin that runs chmod “to fix permissions” can undo section 1’s work in seconds. Test new plugins on staging; monitor file permissions on production. auditd rules on the document root help.

“Hide WP” plugins are not security

Plugins that “hide that this is WordPress” (renaming /wp-admin, suppressing the generator meta tag) buy a small reduction in opportunistic-scan noise. Every real attacker fingerprints WordPress in under a second. Do not let these plugins justify weakening any of the controls above.

Backups in the document root

A common mis-configuration: a backup plugin writes its output to /wp-content/backups/. That directory is then served by Nginx. A predictable filename + a backup containing the entire database equals total compromise. Block backup directory access in Nginx (already covered by the \.(?!well-known) rule if you prefix with a dot) and write backups outside the document root.

What this guide deliberately does not cover

  • WooCommerce-specific hardening (PCI scope) — separate guide.
  • WP-CLI deployment pipelines in depth — separate guide.
  • WordPress on Docker / Kubernetes — out of scope for this site.
  • Migrating off WordPress — sometimes the right answer, but a different conversation.