Step 4: Directory protection and housekeeping
These are the same drop.conf rules that apply to any site — deny hidden files, backup files, and common config extensions:
# drop.conf
# Deny hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Deny backup files
location ~ ~$ {
deny all;
access_log off;
log_not_found off;
}
# Deny sensitive extensions
location ~* \.(yml|yaml|env|bak|swp|dist|config|tfstate|tfvars)$ {
deny all;
access_log off;
log_not_found off;
}
# Handle favicon.ico cleanly
location = /favicon.ico {
access_log off;
log_not_found off;
try_files $uri =204;
}
# Handle robots.txt — WordPress generates this dynamically
location = /robots.txt {
try_files $uri /index.php;
}
If you're using Let's Encrypt with certbot, the renewal challenge needs a .well-known/acme-challenge path accessible on port 80 and 443 (if auto redirecting non-SSL traffic). Rather than scattering this across individual server blocks, it lives in listen.conf — a consolidated snippet that handles SSL, HSTS, and the ACME renewal path in one include. The Securing Nginx and PHP guide covers the full listen.conf setup. Without it, certbot's automated renewal fails silently at the next expiry.
Step 5: Static file caching
WordPress themes and plugins ship a lot of static assets. Caching them aggressively at the Nginx level keeps PHP-FPM free for requests that actually need it. Save as static.conf:
# static.conf
# Images and fonts — immutable, cache as long as possible
location ~* \.(png|jpg|jpeg|gif|ico|webp|woff2|woff|ttf)$ {
try_files $uri =404;
expires max;
add_header Pragma public;
add_header Cache-Control "public, no-transform";
}
# CSS, JS, SVG, text — cache aggressively but allow revalidation
location ~* \.(css|js|html|htm|txt|xml|svg)$ {
gzip_static on;
try_files $uri =404;
expires max;
add_header Pragma public;
add_header Cache-Control "public, must-revalidate, proxy-revalidate";
}
try_files $uri =404; in both blocks ensures missing static files return a clean 404 instead of falling through to WordPress — saving a PHP-FPM worker and a database query for every broken asset reference. If you're serving WebP for the image blocks, the WebP guide covers the try_files with content negotiation and Vary: Accept header — many themes and plugins handle WebP generation themselves now, but the Nginx approach keeps it independent of your CMS if you're doing conversions after the fact.
Step 6: PHP handler and FastCGI tuning
WordPress benefits from PHP-FPM tuning at both the Nginx and pool levels. Save as php.conf:
# php.conf
location ~ \.php$ {
try_files $uri =404;
fastcgi_intercept_errors on;
fastcgi_ignore_client_abort on;
# Use fastcgi.conf if your distro ships it (Debian/Ubuntu);
# fastcgi_params is fine — just ensure SCRIPT_FILENAME is present
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.5-fpm.sock;
}
If your Nginx installation ships a fastcgi.conf file (common on Debian/Ubuntu builds), use it instead of fastcgi_params — it includes the full set of expected CGI variables like SCRIPT_FILENAME, HTTP_HOST, and HTTPS. Installations from the official nginx.org APT repository typically only include fastcgi_params. That's fine — just make sure your PHP handler provides the equivalent variables, especially SCRIPT_FILENAME and HTTP_HOST. The rest of the variables in fastcgi.conf are optional or legacy.
At the nginx.conf http { } level, these FastCGI directives optimize how Nginx talks to PHP-FPM for a WordPress workload:
# In http { } — applies to all PHP locations
fastcgi_intercept_errors on;
fastcgi_ignore_client_abort on;
fastcgi_max_temp_file_size 0;
fastcgi_buffers 16 16k;
fastcgi_read_timeout 180;
fastcgi_intercept_errors on — Lets Nginx serve its own error pages instead of passing PHP error responses through. Cleaner 404s and 50x pages without involving WordPress.
fastcgi_ignore_client_abort on — PHP-FPM finishes processing even if the visitor closes their browser. Critical for wp-cron and scheduled tasks that are triggered by page loads — without it, a user who navigates away mid-request cancels the backend work.
fastcgi_max_temp_file_size 0 — Disables buffering PHP responses to disk. Keeps everything in memory and avoids I/O bottlenecks on busy sites. Only safe if your PHP responses fit within your buffer configuration (which they will for normal WordPress pages).
fastcgi_buffers 16 16k — 16 buffers of 16 KB each (256 KB total). Fewer, larger buffers outperform many small ones for typical WordPress page responses — the admin area, plugin pages, and certain themes push more markup than a typical PHP app.
fastcgi_read_timeout 180 — 180-second timeout for PHP responses. Accommodates slow WordPress admin operations like plugin updates, media processing, and backup generation that can take well over the default 60 seconds.
On the PHP-FPM side, make sure cgi.fix_pathinfo = 0 in your php.ini. When enabled, PHP tries to guess which file you meant if the requested path doesn't exist. When disabled, it only executes the exact file requested — defense in depth alongside try_files $uri =404;.
For servers running multiple WordPress installs (or WordPress alongside other PHP applications), open_basedir is the next hardening step worth taking — it restricts each site's PHP processes to its own document root so a compromised plugin on one site can't read wp-config.php from another. Database-level separation matters too: each site should use its own database with a dedicated user that only has privileges on that database. The Securing Nginx and PHP guide walks through both — open_basedir configuration, per-site PHP-FPM pools, and the principle of least privilege applied to your PHP runtime.