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 6.9.4 — the same stack running behind this site — with security 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 6.9 requires PHP 7.4+, but 8.1+ is recommended)
- 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-6.9.4.zip
sudo unzip wordpress-6.9.4.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;
ssl_trusted_certificate /etc/letsencrypt/live/mysite.com/chain.pem;
root /var/www/mysite.com/public_html;
access_log /var/log/nginx/mysite.access.log;
error_log /var/log/nginx/mysite.error.log;
client_max_body_size 8M;
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.3-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.client_max_body_size 8M;— Match this to your largest expected media upload. Set it at the server-block level, not globally — Nginx buffers request bodies to disk, and a globally large limit combined with many connections can exhaust disk space during an attack.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.
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 security blocks
These rules block the most common WordPress attack vectors. All of them must appear before the general location ~ \.php$ handler — Nginx processes regex locations in order, and the first match wins.
# wp_security.conf — blocks for known WordPress attack surfaces
# Block PHP execution in the uploads directory
location ~* ^/wp-content/uploads/.*\.php$ {
deny all;
access_log off;
log_not_found off;
}
# Block access to sensitive WordPress files
location ~* ^/(wp-config\.php|readme\.html|license\.txt) {
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;
}
# Force no caching on the admin area
location /wp-admin {
expires 0;
}
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.
On sites where WordPress is already installed, also block the setup-config installer:
# wp_installed.conf — block the installer on existing sites
location = /wp-admin/setup-config.php {
deny all;
access_log off;
log_not_found off;
}