How to Harden Nginx for PHP and Drupal Sites

Running a Drupal site on Nginx means you are responsible for your own security posture. The default Nginx configuration is permissive — it is built for compatibility, not hardening. This guide walks through every layer of a production-grade Nginx configuration for PHP and Drupal sites: from hiding server fingerprints and locking down sensitive files, to security headers, TLS hardening, rate limiting, and PHP-FPM settings.

All examples are tested against Nginx 1.24+ and Drupal 10/11. Adapt paths to match your server layout.


1. Hide Server Information

The first thing an attacker does is fingerprint your stack. Nginx happily broadcasts its version in response headers and error pages by default. Turn that off.

# nginx.conf — http block
server_tokens off;

This removes the version number from the Server header and from built-in error pages. For full header removal you need the headers-more module (included in nginx-extras on Debian/Ubuntu):

more_clear_headers Server;
# Or replace it with a generic value:
more_set_headers "Server: webserver";

Similarly, suppress PHP version exposure at the FastCGI level and in php.ini:

fastcgi_hide_header X-Powered-By;
; php.ini
expose_php = Off

2. Block Access to Sensitive Files and Directories

Drupal projects managed with Composer contain files that must never be publicly accessible: composer.json, composer.lock, the entire .git directory, and Drupal's own settings.php. A misconfigured server exposing any of these is a critical vulnerability.

First and most important: if your Drupal webroot is a Composer-based project, make sure your Nginx root directive points to the web/ subdirectory — not the project root. This alone keeps composer.json and vendor/ off the web.

root /var/www/html/web;

Then add these blocks inside your server block:

# Block dotfiles and dot-directories (.git, .env, .htaccess, etc.)
location ~ /\. {
    deny all;
    access_log off;
    log_not_found off;
}

# Block Composer and package management files
location ~* (composer\.(json|lock)|package\.json|package-lock\.json|yarn\.lock|Makefile|Dockerfile)$ {
    deny all;
    return 404;
}

# Block Drupal file types that should never be served directly
# Covers module/theme source, install files, Twig templates, YAML config, shell scripts
location ~* \.(engine|inc|install|make|module|profile|po|sh|sql|theme|twig|tpl\.php|xtmpl|yml|bak|orig|save|swo|swp)$ {
    deny all;
    return 404;
}

# Block Drupal settings files explicitly
location ~* /sites/.*/settings(\.local)?\.php$ {
    deny all;
    return 404;
}

# Block services.yml (can expose sensitive service configuration)
location ~* /sites/.*/services\.yml$ {
    deny all;
    return 404;
}

# Block access to the vendor and node_modules directories
location ~* ^/(vendor|node_modules)/ {
    deny all;
    return 404;
}

# Block Drupal's private files directory
location ~* /sites/.*/private/ {
    deny all;
    return 404;
}

# Disable directory listing everywhere
autoindex off;

3. Prevent PHP Execution in Upload Directories

One of the most common web shell attacks involves uploading a PHP file disguised as an image or document into a writable directory, then executing it via the browser. Block this by explicitly disabling PHP execution in upload paths.

# Drupal managed files — serve static files only, deny all script execution
location ~* /sites/[^/]+/files/ {
    try_files $uri =404;

    # Block PHP and any other executable extensions
    location ~* \.(php\d?|phtml|pl|py|cgi|asp|aspx|sh|bash|rb|exe)$ {
        deny all;
        return 403;
    }
}

# Block the tmp subdirectory entirely
location ~* /sites/.*/files/tmp/ {
    deny all;
    return 404;
}

Critical: Nginx location blocks are ordered by specificity, not by document order. The nested location for the PHP extension check inside the files path ensures it is evaluated before the generic \.php$ FastCGI block. Always pair this with try_files $uri =404 in your PHP location (see section 7) to prevent path-info injection attacks like /files/shell.jpg/index.php.


4. Security Headers

Security headers instruct browsers on how to behave when loading your site, mitigating XSS, clickjacking, MIME sniffing, and data leakage. Add these to your server block, or extract them to a shared conf.d/security-headers.conf file.

# Prevent clickjacking
add_header X-Frame-Options "SAMEORIGIN" always;

# Prevent MIME type sniffing
add_header X-Content-Type-Options "nosniff" always;

# Control referrer information sent to third parties
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# HTTP Strict Transport Security — 2 years is the Mozilla/OWASP recommended max-age
# Only add 'preload' after verifying every subdomain supports HTTPS
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

# Disable browser features not needed by your site
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;

# Disable the legacy XSS auditor — it can introduce vulnerabilities in old browsers
# and CSP is the modern replacement
add_header X-XSS-Protection "0" always;

# Cross-origin isolation headers (modern browsers)
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-site" always;

The always parameter ensures headers are sent even on error responses (4xx, 5xx) — error pages are also attack surfaces.

Content Security Policy

CSP is the most powerful header but also the most complex to configure correctly. Drupal's admin interface uses inline scripts and styles, so 'unsafe-inline' is typically required unless you implement a nonce-based policy.

Start in report-only mode so you can see violations without breaking anything:

add_header Content-Security-Policy-Report-Only
    "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; frame-ancestors 'self'; report-uri /csp-report" always;

Once you have validated the policy in your browser's console, switch to enforcement:

add_header Content-Security-Policy
    "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; frame-ancestors 'self';" always;

Use Google's CSP Evaluator to audit and tighten your policy over time.


5. SSL/TLS Hardening

SSLv3, TLS 1.0, and TLS 1.1 are deprecated and broken. Configure Nginx to use only TLS 1.2 and 1.3 with modern cipher suites. The configuration below follows Mozilla's Intermediate profile — the recommended balance of security and browser compatibility for public Drupal sites.

# Generate DH params once before using DHE cipher suites:
# openssl dhparam -out /etc/nginx/dhparam.pem 2048

ssl_protocols TLSv1.2 TLSv1.3;

# Mozilla Intermediate cipher suite (2024)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off; # Let TLS 1.3 negotiate freely

# Explicit TLS 1.3 cipher list (Nginx >= 1.19.4)
ssl_conf_command Ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;

# DH params
ssl_dhparam /etc/nginx/dhparam.pem;

# OCSP stapling — reduces handshake latency and improves privacy
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;

# Session settings
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # Disabling tickets improves forward secrecy

Force HTTP to HTTPS with a permanent redirect:

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

Use Mozilla's SSL Configuration Generator to keep your cipher list current — it is updated as new vulnerabilities emerge. Aim for an A+ on SSL Labs.


6. Rate Limiting

Rate limiting protects against brute-force login attacks, credential stuffing, and comment spam. Define zones in the http block, then apply them in server or location blocks.

# nginx.conf — http block
limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=login:10m   rate=5r/m;
limit_conn_zone $binary_remote_addr zone=addr:10m;

# Return 429 Too Many Requests (more semantically correct than the default 503)
limit_req_status 429;
limit_conn_status 429;

Apply in your Drupal server block:

server {
    limit_req zone=general burst=40 nodelay;
    limit_conn addr 20;

    # Stricter limits on Drupal's login and registration pages
    location ~* ^/(user/login|user/register|user/password) {
        limit_req zone=login burst=3 nodelay;
        try_files $uri /index.php?$query_string;
    }
}

If you have internal monitoring or load balancer IPs that should be exempt from rate limits, use a geo/map pair to exclude them:

# http block — allowlist internal IPs
geo $limit {
    default         1;
    10.0.0.0/8      0;
    192.168.0.0/16  0;
}
map $limit $limit_key {
    0   "";
    1   $binary_remote_addr;
}
limit_req_zone $limit_key zone=general:10m rate=20r/s;

When $limit_key is empty for allowlisted IPs, Nginx skips rate limiting for them entirely.


7. PHP-FPM Security

PHP-FPM itself needs hardening. These settings go in your PHP-FPM pool configuration (e.g. /etc/php/8.3/fpm/pool.d/drupal.conf).

[drupal]
; Run as a dedicated unprivileged user — prevents cross-site contamination on shared servers
user  = drupal
group = drupal

; Unix socket — faster than TCP and avoids network exposure
listen       = /var/run/php/php8.3-drupal-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode  = 0660

pm                   = dynamic
pm.max_children      = 20
pm.start_servers     = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests      = 500

; Kill workers that run too long — prevents runaway scripts
request_terminate_timeout = 30s

; Restrict filesystem access to the site root and essential paths only
php_admin_value[open_basedir] = /var/www/html/web:/tmp:/usr/share/php

; Disable dangerous functions
php_admin_value[disable_functions] = dl,exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source,pcntl_exec

; Do not expose PHP version in headers
php_admin_flag[expose_php] = Off

; Session security — harden cookies
php_admin_value[session.cookie_httponly] = 1
php_admin_value[session.cookie_secure]   = 1
php_admin_value[session.use_strict_mode] = 1
php_admin_value[session.cookie_samesite] = Strict

; Never display errors in production
php_admin_flag[display_errors] = Off
php_admin_flag[log_errors]     = On
php_admin_value[error_log]     = /var/log/php/drupal-error.log

; Log slow requests
slowlog                  = /var/log/php-fpm/drupal-slow.log
request_slowlog_timeout  = 5s

On the Nginx side, always use the Unix socket and guard against the PATH_INFO vulnerability with try_files:

location ~ \.php$ {
    # Never pass a request to PHP-FPM if the .php file does not exist on disk.
    # Without this, /uploads/shell.jpg/index.php executes shell.jpg as PHP.
    try_files $uri =404;

    fastcgi_split_path_info ^(.+\.php)(/.+)$;
    fastcgi_pass unix:/var/run/php/php8.3-drupal-fpm.sock;
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO       $fastcgi_path_info;
    fastcgi_hide_header X-Powered-By;

    fastcgi_read_timeout    30;
    fastcgi_connect_timeout 10;
    fastcgi_send_timeout    30;
    fastcgi_buffer_size     128k;
    fastcgi_buffers         4 256k;
}

8. Restrict HTTP Methods

Drupal only needs GET, POST, and HEAD for a standard site. Block everything else at the location level using limit_except, which is more precise than an if block:

location / {
    limit_except GET HEAD POST { deny all; }
    try_files $uri /index.php?$query_string;
}

If you use Drupal's JSON:API or REST module, extend the allowed list for those paths only:

location ~* ^/api/ {
    limit_except GET HEAD POST PUT PATCH DELETE { deny all; }
    try_files $uri /index.php?$query_string;
}

9. Limit Request Sizes and Timeouts

Protect against large request attacks and Slowloris-style connection exhaustion:

# nginx.conf — http block or server block
client_max_body_size        32m;  # Match your Drupal max upload size
client_body_buffer_size     16k;
client_header_buffer_size   1k;
large_client_header_buffers 4 16k;
client_body_timeout         30s;  # Abort slow uploads
client_header_timeout       10s;  # Abort slow header delivery
send_timeout                30s;  # Abort slow response consumption
keepalive_timeout           65s;  # Close idle connections

10. Putting It All Together

Here is a condensed production-ready Nginx server block for a Composer-based Drupal 10/11 site, incorporating everything above:

# http block (nginx.conf)
server_tokens off;

limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=login:10m   rate=5r/m;
limit_conn_zone $binary_remote_addr zone=addr:10m;
limit_req_status 429;
limit_conn_status 429;

# HTTPS server
server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    root  /var/www/html/web;
    index index.php;

    autoindex off;

    # --- TLS ---
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_dhparam /etc/nginx/dhparam.pem;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 8.8.8.8 valid=300s;

    # --- Security headers ---
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
    add_header X-XSS-Protection "0" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Resource-Policy "same-site" always;

    # --- Rate limiting ---
    limit_req zone=general burst=40 nodelay;
    limit_conn addr 20;

    # --- Request size limits ---
    client_max_body_size    32m;
    client_body_timeout     30s;
    client_header_timeout   10s;

    # --- Drupal front controller ---
    location / {
        limit_except GET HEAD POST { deny all; }
        try_files $uri /index.php?$query_string;
    }

    # PHP execution
    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/var/run/php/php8.3-drupal-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO       $fastcgi_path_info;
        fastcgi_hide_header X-Powered-By;
        fastcgi_read_timeout 30;
    }

    # Drupal login — strict rate limit
    location ~* ^/(user/login|user/register|user/password) {
        limit_req zone=login burst=3 nodelay;
        try_files $uri /index.php?$query_string;
    }

    # Managed files — static only, no script execution
    location ~* /sites/[^/]+/files/ {
        try_files $uri =404;
        location ~* \.(php\d?|phtml|pl|py|cgi|sh|bash)$ {
            deny all;
            return 403;
        }
    }

    # Block dotfiles (.git, .env, .htaccess, etc.)
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }

    # Block Drupal internals and Composer files
    location ~* \.(engine|inc|install|make|module|profile|po|sh|sql|theme|twig|tpl\.php|xtmpl|yml|bak|orig|save|swo|swp)$ {
        deny all;
        return 404;
    }

    location ~* (composer\.(json|lock)|package\.json|yarn\.lock|Makefile)$ {
        deny all;
        return 404;
    }

    location ~* ^/(vendor|node_modules)/ {
        deny all;
        return 404;
    }

    # Static file caching
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
        log_not_found off;
    }
}

# HTTP → HTTPS redirect
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

Testing Your Configuration

After making changes, always test the syntax before reloading:

nginx -t && nginx -s reload

Then verify your hardening with these free tools:


Summary

Hardening Nginx for a Drupal/PHP site is not a single setting — it is a set of layered defences:

  • Hide server and PHP version information to slow down fingerprinting.
  • Point the webroot at web/ and block sensitive Drupal file types and directories.
  • Prevent PHP execution in upload directories to block web shells.
  • Add security headers — HSTS, CSP, X-Frame-Options, CORP, and friends.
  • Enforce TLS 1.2/1.3 only with modern AEAD cipher suites.
  • Rate-limit login pages and sensitive endpoints; allowlist internal IPs.
  • Harden PHP-FPM with per-site users, open_basedir, disabled dangerous functions, and secure session settings.
  • Set request size and timeout limits to blunt slow-connection attacks.

Each of these steps individually reduces risk; together they make your site a significantly harder target. Run the testing tools above after each change and aim for an A+ on SSL Labs and a green score on Security Headers — both are achievable with the configuration in this guide.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.