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
# 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
# Set ownership
sudo chown -R www-data: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;
}