Securing Nginx and PHP

This guide assumes you've already locked down SSH. If you haven't, start with Hardening SSH on Linux — you can't secure your web stack if anyone can guess your root password.

Nginx Configuration

Keepalive timeout. The default 65 seconds is generous. Lower it to reduce the number of idle connections during traffic spikes:

keepalive_timeout 15s;

Client body size. The default is 1M. Increase it only where uploads are expected — in a specific server { } block or location, not globally in http { }. Nginx buffers request bodies to disk, and a globally large client_max_body_size combined with many connections can exhaust disk space during an attack.

# In a specific server block — NOT in http { }
client_max_body_size 8M;

Match this to your PHP upload_max_filesize and post_max_size values.

Deny hidden files and backup files. These belong in every server block:

# Deny hidden files (anything starting with a dot)
location ~ /\. {
    deny all;
    access_log off;
    log_not_found off;
}

# Deny vim/text-editor backup files
location ~ ~$ {
    deny all;
    access_log off;
    log_not_found off;
}

# Deny common sensitive/config file extensions
location ~* \.(yml|yaml|env|bak|swp|dist|config|tfstate|tfvars)$ {
    deny all;
    access_log off;
    log_not_found off;
}

The hidden-files rule is broader than just blocking .htaccess — it covers .git, .env, and any other dot-file that might leak information. The extension list includes tfstate and tfvars for Terraform users — if you store infrastructure-as-code in a web-accessible directory, those files expose your entire cloud configuration.

Rate-limiting deny blocks: A bot that hits 20 blocked paths in 60 seconds gets a 403 on every single one, but each request still consumes a small amount of Nginx worker time. Adding a limit_req zone to your deny blocks drops excess requests before they even reach the location handler:

# In http { }
limit_req_zone $binary_remote_addr zone=botstop:1m rate=1r/m;

# On each deny block
location ~ /\. {
    limit_req zone=botstop;
    limit_req_status 403;
    deny all;
    access_log off;
    log_not_found off;
}

One request per minute to any denied path. The first hit gets logged and dropped. The next 19 in the same minute get dropped silently at the rate-limit layer — Nginx never even evaluates the location block. This sits between Nginx's deny all (instant but stateless) and fail2ban (stateful but delayed by findtime/maxretry thresholds). For the full fail2ban + nftables integration, see the fail2ban guide.

Protect the PHP handler with try_files. Never pass a non-existent PHP file to the backend:

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

Without try_files $uri =404;, a request for /uploads/avatar.png/index.php could trick PHP into executing a file disguised as an image. With it, the request gets a 404 before PHP ever sees it.


PHP Configuration

These go in your php.ini or PHP-FPM pool configuration:

Disable cgi.fix_pathinfo:

cgi.fix_pathinfo = 0

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. This works alongside the try_files $uri =404; rule in Nginx for defense in depth.

Restrict PHP's filesystem access with open_basedir:

# In your PHP location block — via fastcgi_param
fastcgi_param PHP_ADMIN_VALUE "open_basedir=$document_root:/usr/share/php/:/var/www/tmp:/tmp";

open_basedir confines PHP to a whitelist of directories. Even if a compromised plugin tries to file_get_contents('/etc/passwd') or include('/var/www/other-site/wp-config.php'), PHP refuses the request at the engine level — before any application code runs. The paths above allow the site's document root, system PHP libraries (/usr/share/php), and temporary directories for uploads and sessions. On a server with multiple WordPress installs, this prevents a vulnerability in one site from reading files belonging to another.

Filesystem isolation is half the picture. Each site should also use its own database with a dedicated MySQL or MariaDB user — one that only has privileges on that database. If a compromised plugin gains the ability to execute arbitrary SQL, a shared database user with cross-database access means all your sites' tables are exposed. A per-site user scoped to a single database limits the blast radius to that one install.

Limit memory per script:

memory_limit = 64M

Set this low and raise it only as needed. Many CMS platforms can override this in their own configuration if you grant them the capability.

Hide errors from visitors:

display_errors = Off
log_errors = On

Errors on screen help during development but leak information in production. Log them instead and handle failures gracefully.

Match upload limits to Nginx:

post_max_size = 8M
upload_max_filesize = 8M

Keep these consistent with client_max_body_size in Nginx. There's no point allowing PHP to accept 64M uploads if Nginx cuts the connection at 8M.


PHP-FPM Process Tuning

PHP-FPM processes only handle one request at a time. The default pool configuration often allows far more children than a small VPS can support. In your FPM pool config (www.conf or similar):

pm = static
pm.max_children = 4

Start low and monitor. Each PHP process uses memory — 4 to 8 children is a reasonable starting point for a 1–2 GB VPS. Set pm.max_requests to recycle workers periodically (e.g., 500) to guard against slow memory leaks.