Securing Nginx and PHP

Connection Limiting in Nginx

PHP-FPM can't handle nearly as many concurrent requests as Nginx. Without limits, one aggressive user can exhaust your PHP pool and serve 502/504 errors to everyone else.

In your nginx.conf:

limit_conn_zone $binary_remote_addr zone=phplimit:1m;

In your PHP location block (php.conf):

# php.conf
location ~ \.php$ {
    try_files $uri =404;
    limit_conn phplimit 5;
    include fastcgi_params;
    fastcgi_pass unix:/var/run/php/php8.5-fpm.sock;
}

This caps any single IP to 5 concurrent PHP requests. Legitimate users behind shared IPs (schools, offices) still get through while an attacker hammering your contact form gets throttled. Adjust the number up or down based on your traffic patterns.


Rate Limiting

For endpoints that do expensive work — search, login forms, API endpoints — rate limiting prevents abuse. Define a zone in nginx.conf:

limit_req_zone $binary_remote_addr zone=searchlimit:10m rate=1r/s;

Apply it to a specific location:

location /search {
    limit_req zone=searchlimit burst=3 nodelay;
    try_files $uri /index.php$is_args$args;
}

This allows one search request per second with a burst of 3. For more on this, see Preventing Search Overload.


WordPress-Specific Blocks

If you run WordPress, these rules go in wp.conf — a CMS-specific snippet included before the general location ~ \.php$ handler. Nginx processes regex locations in order, and the first match wins:

# 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 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;
}

wp-config.php contains your database credentials. readme.html leaks your WordPress version. license.txt confirms you're running WordPress. WordPress does attempt to protect these internally, but by the time the request reaches PHP it has already consumed a PHP-FPM worker, spun up the WordPress bootstrap, and burned CPU cycles just to return a 403. Letting Nginx drop the request before PHP ever sees it saves that overhead — and during a bot swarm hammering /wp-config.php, that adds up fast.

The /wp-admin block does three things: expires 0 prevents browsers from caching admin pages, client_max_body_size 16M scopes uploads to where they actually happen, and the explicit try_files with $is_args$args preserves query strings through the internal redirect — essential for plugin pages with long query strings.

For sites where WordPress is already installed, also block the setup-config installer. Save as wp-installed.conf:

# wp-installed.conf
location = /wp-admin/setup-config.php {
    deny all;
    access_log off;
    log_not_found off;
}

These blocks are the essentials. For the full production wp.conf — including sitemap caching rules, static caching, rate limiting, and PHP-FPM tuning — see the WordPress on Nginx installation guide.


honeypot.conf — the catch-all HTTPS server block

Every request that doesn't match a defined server_name lands in the catch-all. Rather than letting these silently accumulate in your error log, a dedicated snippet denies the same patterns as drop.conf, wp.conf, and bludit.conf — but without rate limiting, since these requests are already being dropped:

# honeypot.conf — deny patterns for the HTTPS catch-all
# No limit_req — these requests are already in the noise

# drop.conf patterns
location ~* /cgi-bin/                                     { deny all; }
location ~* \.(yml|yaml|tfstate|tfvars|config|env|bak|swp|dist)$ { deny all; }
location ~ /\.                                             { deny all; }
location ~ ~$                                              { deny all; }

# wp.conf patterns
location ~* ^/(wp-config\.php|wp-settings\.php|wp-mail\.php|readme\.html|license\.txt) { deny all; }
location ~* ^/wp-content/uploads/.*\.php$                  { deny all; }
location = /xmlrpc.php                                     { deny all; }
location = /wp-admin                                        { deny all; }
location ~* ^/wp-admin/                                     { deny all; }

# bludit.conf patterns
location ^~ /bl-content/databases/                         { deny all; }
location ^~ /bl-content/workspaces/                        { deny all; }
location ^~ /bl-content/pages/                             { deny all; }
location ~* ^/bl-kernel/.*\.php$                            { deny all; }

# Everything else — silent close
location / {
    return 444;
}

Included in the HTTPS catch-all server block (server_name _;), this logs every unmatched request separately for fail2ban to watch, then silently closes the connection. Bots that probe random hostnames never reach a real site. The fail2ban guide covers the catch-all integration — the nginx-forbidden jail watches this log and bans repeat offenders at the firewall.


Further Reading

Technical Audit Summary

This guide is maintained as part of a modular, SSL-first framework. Each configuration is audited for production stability and modern security standards.

Last Audit: June 2026
Environment: Debian Trixie (13)
Nginx: 1.31.1
PHP-FPM: 8.5.6

Compatibility: Tested against current stable releases. While optimized for the stack above, core logic remains relevant for Nginx 1.26+ and PHP 8.2+ environments.

2026-06-07: Restructured around the modular snippet approach. Added listen.conf section — consolidated SSL, HSTS, and ACME renewal in one include that always runs first. Added honeypot.conf section — combined deny patterns for the HTTPS catch-all server block. Updated drop.conf to reflect that ACME paths moved to listen.conf. Updated WordPress blocks to match current production wp.conf: renamed from wp_security.conf, added explicit try_files to /wp-admin, bumped client_max_body_size to 16M with php.ini matching note, expanded sensitive-file deny list. Removed ssl_trusted_certificate — Let's Encrypt phased out OCSP stapling. client_max_body_size now scoped to admin location blocks, not server-wide. Bumped Nginx to 1.31.1, fixed all root-relative links.

2026-05-26: Added database security hygiene note — per-site MySQL users scoped to a single database as the complement to open_basedir filesystem isolation.

2026-05-21: Production audit — synced stack versions. Added open_basedir restriction, botstop rate-limiting pattern, tfstate/tfvars to sensitive extension list, client_max_body_size to /wp-admin location.