WordPress doesn’t use .htaccess on Nginx, so the server block does all the work: pretty permalinks, PHP handling, caching headers and security rules. Here’s a complete, annotated configuration you can adapt, followed by the reasoning for each part.
New install? Start with WordPress on Ubuntu with Nginx.
The full server block
# /etc/nginx/sites-available/example.com
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
# ssl_certificate / ssl_certificate_key managed by Certbot
root /var/www/example.com;
index index.php;
client_max_body_size 64M; # match upload_max_filesize in PHP
# --- Permalinks ---
location / {
try_files $uri $uri/ /index.php?$args;
}
# --- PHP ---
location ~ \.php$ {
try_files $uri =404; # don't pass non-existent files to PHP
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_read_timeout 120s;
}
# --- Static files: long cache, no logs ---
location ~* \.(?:css|js|mjs|jpg|jpeg|gif|png|webp|avif|svg|ico|woff2?|ttf|eot|mp4)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
access_log off;
try_files $uri =404;
}
# --- Security ---
location = /xmlrpc.php { deny all; access_log off; }
location ~* /wp-content/uploads/.*\.php$ { deny all; }
location ~* /(?:wp-content|wp-includes)/.*\.php$ {
deny all; # see note about plugins that need direct PHP access
}
location ~ /\.(?!well-known) { deny all; }
location ~* (?:wp-config\.php|readme\.html|license\.txt|debug\.log)$ { deny all; }
# --- Rate-limit logins (zone defined in nginx.conf) ---
location = /wp-login.php {
limit_req zone=wplogin burst=5 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
# --- Quiet common noise ---
location = /favicon.ico { log_not_found off; access_log off; }
location = /robots.txt { log_not_found off; access_log off; try_files $uri /index.php?$args; }
}
In /etc/nginx/nginx.conf (inside http {}):
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=10r/m;
Test and reload: sudo nginx -t && sudo systemctl reload nginx. See Nginx config testing.
Why each part matters
Permalinks: try_files $uri $uri/ /index.php?$args serves real files directly, and sends everything else to WordPress. Without it, every pretty URL returns 404. See WordPress permalinks 404.
try_files $uri =404 in the PHP block: stops Nginx from passing requests for non-existent .php files to PHP-FPM, which is a classic security issue with some configurations.
Static files: browsers cache theme assets and images for 30 days. WordPress appends ?ver= to CSS and JS, so updates still bust the cache. Logging off reduces disk I/O. See Nginx static file caching.
Blocking PHP in uploads: uploaded files should never be executable. This one rule stops many malware infections from running even if a file gets uploaded.
Blocking PHP in wp-content and wp-includes: a strong hardening rule. Some plugins call their own PHP files directly. If something breaks, add a specific location exception for that plugin path, above this rule.
xmlrpc.php: a common target for brute force and pingback abuse. Block it unless you use the Jetpack or WordPress mobile app features that need it. See disable XML-RPC.
wp-login rate limit: slows down password guessing without affecting real users. Combine it with Fail2ban. See Fail2ban.
Hidden files: blocks .git, .env and .htaccess leftovers, but allows .well-known (Let’s Encrypt).
Compression
Enable gzip (or Brotli) for text assets in nginx.conf:
gzip on;
gzip_comp_level 5;
gzip_types text/css application/javascript application/json image/svg+xml text/plain application/xml;
Add page caching (optional, big win)
For mostly-anonymous traffic, FastCGI cache serves cached HTML straight from Nginx, without touching PHP, and skips logged-in users and carts. See Nginx FastCGI cache. If you use a caching plugin that writes static HTML files instead, add its Nginx rules. Don’t stack two page caches. See WordPress cache plugins compared.
Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000" always;
Add headers in the server block. Note that a location with its own add_header doesn’t inherit these, so repeat them there, or use an include snippet.
Multisite
Subdirectory multisite needs extra rewrite rules for /site/wp-admin and file paths. See WordPress multisite setup.
Quick reference
| Goal | Directive |
|---|---|
| Pretty permalinks | try_files $uri $uri/ /index.php?$args; |
| Upload size | client_max_body_size 64M; |
| No PHP in uploads | location ~* /wp-content/uploads/.*\.php$ { deny all; } |
| Block XML-RPC | location = /xmlrpc.php { deny all; } |
| Login rate limit | limit_req zone=wplogin … |
Related: WordPress hardening and WordPress VPS hosting.