WordPress powers a staggering share of the web, and its Nginx configuration has been refined over years of real-world abuse. This guide walks through a production setup for WordPress 7.0 — the same stack running behind this site — with CMS-specific blocks, static caching, rate limiting, and PHP-FPM tuning that actually makes a difference under load.
Prerequisites
- Nginx 1.18+
- PHP-FPM 8.1+ (WordPress 7.0 requires PHP 7.4+, but 8.1+ is recommended)
- MySQL 8.0+ or MariaDB 10.6+ (WordPress 7.0 raised the database minimum from MySQL 5.5.5)
- PHP extensions:
mysqli(ormysqlnd),curl,json,mbstring,gd(orimagick),zip - APCu and opcache enabled for object caching and PHP acceleration
- A domain name pointed to your server
- SSL certificates via Let's Encrypt
Step 1: Download WordPress
The ownership model here is deliberate: files are owned by your SSH/SFTP user with www-data as the group. PHP-FPM runs as www-data — the standard Debian/Ubuntu default. Most of WordPress should not be writable by PHP; only a handful of directories get group-write permissions so WordPress can handle uploads, plugin installs, and updates from the admin panel.
# Create the web root
sudo mkdir -p /var/www/mysite.com/public_html
# Download WordPress
cd /tmp
wget https://wordpress.org/wordpress-7.0.zip
sudo unzip wordpress-7.0.zip -d /var/www/mysite.com/public_html
# Ownership: your user owns the files, www-data is the group.
# Replace 'user' with your actual SSH/SFTP username.
sudo chown -R user:www-data /var/www/mysite.com/public_html
# Base permissions: 755 on directories, 644 on files.
# These are the only permissions the vast majority of WordPress needs.
# Nginx (www-data) gets read+traverse via the group bits.
sudo find /var/www/mysite.com/public_html -type d -exec chmod 755 {} \;
sudo find /var/www/mysite.com/public_html -type f -exec chmod 644 {} \;
# WordPress needs to write to these four directories from the admin panel.
# 775 = owner (user) full, group (www-data) full, others read+traverse.
# 664 = owner full, group full, others read-only.
# PHP-FPM runs as www-data, so group-write is what lets it create and
# delete files here — and nowhere else.
sudo find /var/www/mysite.com/public_html/wp-content/uploads -type d -exec chmod 775 {} \;
sudo find /var/www/mysite.com/public_html/wp-content/uploads -type f -exec chmod 664 {} \;
sudo find /var/www/mysite.com/public_html/wp-content/plugins -type d -exec chmod 775 {} \;
sudo find /var/www/mysite.com/public_html/wp-content/plugins -type f -exec chmod 664 {} \;
sudo find /var/www/mysite.com/public_html/wp-content/themes -type d -exec chmod 775 {} \;
sudo find /var/www/mysite.com/public_html/wp-content/themes -type f -exec chmod 664 {} \;
# wp-content/upgrade is used during core/plugin/theme updates and may
# not exist on a fresh install — create it if it's missing
sudo mkdir -p /var/www/mysite.com/public_html/wp-content/upgrade
sudo chown user:www-data /var/www/mysite.com/public_html/wp-content/upgrade
sudo chmod 775 /var/www/mysite.com/public_html/wp-content/upgrade
Why this model. The common shortcut is chown -R www-data:www-data on everything — that means your SSH/SFTP user doesn't own anything, every edit requires sudo, and PHP (as the file owner) can write to every file and directory regardless of the permission bits. Flipping it around — you own the files, www-data is only the group — means you can SSH in and edit files directly. More importantly, PHP cannot write anywhere except the four directories above: wp-content/uploads, wp-content/plugins, wp-content/themes, and wp-content/upgrade. The rest of the install stays at 755/644, which PHP sees as read-only through the group bits. A compromised plugin that tries to modify index.php or wp-settings.php hits a permissions wall. You may still require sudo if you need to modify or remove content created with the ownership www-data:www-data from the shell.
If your system user was created with its own private group (e.g. user:user), run this first so www-data becomes the group:
sudo chown -R user:www-data /var/www/mysite.com/public_html
Step 2: Base Nginx server block
This is the smallest configuration that will get WordPress running. As with any minimal config, it omits security hardening and static caching — the full production version is at the end of the article.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name mysite.com;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
ssl_certificate /etc/letsencrypt/live/mysite.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mysite.com/privkey.pem;
root /var/www/mysite.com/public_html;
access_log /var/log/nginx/mysite.access.log;
error_log /var/log/nginx/mysite.error.log;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.5-fpm.sock;
}
}
try_files $uri $uri/ /index.php$is_args$args;— The standard WordPress front controller. Static files and directories are served directly; everything else routes throughindex.phpwith query strings preserved. WordPress handles its own permalink routing from there.try_files $uri =404;in the PHP block prevents WordPress from executing non-existent PHP files — a request for/uploads/hack.png/index.phpgets a 404 before PHP ever sees it.- No
client_max_body_sizeat the server-block level — uploads belong in the admin area. The/wp-adminlocation block in the next section sets this instead. Whatever you set there, match it inphp.ini:upload_max_filesizeshould equal the Nginx limit, andpost_max_sizeshould be a couple megabytes larger to accommodate the rest of the form data. A mismatch means either Nginx rejects the upload before PHP sees it, or PHP accepts body payloads Nginx already dropped.
The ssl_trusted_certificate directive is absent here — Let's Encrypt phased out OCSP stapling in 2025, and the chain certificate is already bundled in fullchain.pem. Only ssl_certificate and ssl_certificate_key are needed.
At this point you can visit https://mysite.com and run the famous five-minute WordPress installer. But don't stop here — the next sections are where the real configuration happens.
Step 3: WordPress-specific blocks
These rules handle security and routing for WordPress. All of them must appear before the general location ~ \.php$ handler — Nginx processes regex locations in order, and the first match wins. Save this as wp.conf and include it from your server block:
# wp.conf — WordPress security and routing
# Block access to sensitive WordPress files
location ~* ^/(wp-config\.php|wp-settings\.php|wp-mail\.php|readme\.html|license\.txt) {
deny all;
access_log off;
log_not_found off;
}
# Block PHP execution in the uploads directory
location ~* ^/wp-content/uploads/.*\.php$ {
deny all;
access_log off;
log_not_found off;
}
# Block xmlrpc.php (major brute-force and DDoS vector)
location = /xmlrpc.php {
deny all;
access_log off;
log_not_found off;
}
# Admin area — no caching, body size for uploads, explicit front-controller routing
location /wp-admin {
expires 0;
client_max_body_size 16M;
try_files $uri $uri/ /index.php$is_args$args;
}
# Sitemap routes — pass through to WordPress, cache for 1 hour
location = /sitemap.xml { try_files $uri /index.php$is_args$args; expires 1h; }
location = /wp-sitemap.xml { try_files $uri /index.php$is_args$args; expires 1h; }
location = /sitemap_index.xml { try_files $uri /index.php$is_args$args; expires 1h; }
location ~* ^/wp-sitemap-.*\.xml$ {
try_files $uri /index.php$is_args$args;
expires 1h;
}
The /wp-admin block does three things: expires 0 prevents browsers from caching admin pages (stale admin pages cause more problems than they solve), client_max_body_size 16M scopes uploads to where they actually happen instead of the entire server block, and the explicit try_files ensures query strings survive the internal redirect — $is_args$args appended to /index.php is what lets plugin pages with long query strings (/wp-admin/admin.php?page=some-plugin&tab=settings) route correctly.
For themes that import demo content — Avada being a common example — the default 16 MB may not be enough. Bump client_max_body_size to 32M in this block and match it in php.ini with upload_max_filesize = 32M and post_max_size = 34M. For most WordPress sites where the heaviest upload is a photo or a PDF, 16 MB is plenty.
A note on xmlrpc.php: If you use the WordPress mobile app or Jetpack, you need XML-RPC. For everyone else, blocking it eliminates a persistent source of brute-force login attempts and pingback amplification attacks. Nginx dropping the request before PHP sees it saves CPU cycles and PHP-FPM workers that would otherwise spin up for every bot hammering the endpoint.
Why sitemap rules are here: SEO plugins like Yoast and Rank Math generate sitemaps dynamically through WordPress. Without explicit location blocks for sitemap paths, those requests fall through to the generic location / handler — which works, but the try_files fallback to index.php means Nginx can't apply any caching headers. Adding these rules lets you set expires 1h on sitemap responses, keeping crawlers from re-fetching them on every pass. The paths cover the core WordPress sitemap (/wp-sitemap.xml and its paginated variants), the common Yoast/Rank Math single-sitemap convention (/sitemap.xml and /sitemap_index.xml), and any plugin that writes to the root.
On sites where WordPress is already installed, also block the setup-config installer. Save as wp-installed.conf:
# wp-installed.conf — block the installer on existing sites
location = /wp-admin/setup-config.php {
deny all;
access_log off;
log_not_found off;
}