Drupal¶
Production-ready NGINX configuration for Drupal 9 and 10 with security hardening and performance optimization.
Quick Start (Drupal 10)¶
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
http2 on;
listen [::]:443 ssl;
server_name example.com www.example.com;
root /var/www/drupal/web;
index index.php;
# SSL
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_protocols TLSv1.2 TLSv1.3;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Logging
access_log /var/log/nginx/drupal.access.log;
error_log /var/log/nginx/drupal.error.log;
# Favicon and robots
location = /favicon.ico { log_not_found off; access_log off; }
location = /robots.txt { log_not_found off; access_log off; allow all; }
# Block access to hidden files (except .well-known)
location ~ (^|/)\. {
return 403;
}
location ^~ /.well-known/ {
allow all;
}
# Block access to sensitive files
location ~* \.(engine|inc|install|make|module|profile|po|sh|.*sql|theme|twig|tpl(\.php)?|xtmpl|yml)(~|\.sw[op]|\.bak|\.orig|\.save)?$|^/(\.(?!well-known).*|Entries.*|Repository|Root|Tag|Template|composer\.(json|lock)|web\.config)$|^#.*#$|\.php(~|\.sw[op]|\.bak|\.orig|\.save)$ {
deny all;
return 404;
}
# Block text/log files access
location ~* \.(txt|log)$ {
allow 127.0.0.1;
deny all;
}
# Block PHP in wrong locations
location ~ \..*/.*\.php$ { return 403; }
location ~ ^/sites/.*/private/ { return 403; }
location ~ ^/sites/[^/]+/files/.*\.php$ { deny all; }
location ~ /vendor/.*\.php$ { deny all; return 404; }
# Main location
location / {
try_files $uri /index.php?$query_string;
}
# PHP handling
location ~ '\.php$|^/update.php' {
fastcgi_split_path_info ^(.+?\.php)(|/.*)$;
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param HTTP_PROXY "";
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param QUERY_STRING $query_string;
fastcgi_intercept_errors on;
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_read_timeout 300;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
}
# Static files
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
try_files $uri @rewrite;
expires 1y;
add_header Cache-Control "public, immutable";
log_not_found off;
access_log off;
}
# Image styles
location ~ ^/sites/.*/files/styles/ {
try_files $uri @rewrite;
}
# Private files
location ~ ^(/[a-z\-]+)?/system/files/ {
try_files $uri /index.php?$query_string;
}
location @rewrite {
rewrite ^ /index.php;
}
# Enforce clean URLs
if ($request_uri ~* "^(.*/)index\.php/(.*)") {
return 307 $1$2;
}
}
Security Hardening¶
Rate Limiting¶
limit_req_zone $binary_remote_addr zone=drupal:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=drupal_login:10m rate=1r/s;
server {
# General rate limit
limit_req zone=drupal burst=20 nodelay;
# Strict limit on login
location = /user/login {
limit_req zone=drupal_login burst=3 nodelay;
try_files $uri /index.php?$query_string;
}
location = /user/register {
limit_req zone=drupal_login burst=3 nodelay;
try_files $uri /index.php?$query_string;
}
}
Block Common Attacks¶
# Block suspicious query strings
if ($query_string ~* "(base64_encode|base64_decode|eval\(|union.*select|script>|<script)") {
return 403;
}
# Block suspicious user agents
if ($http_user_agent ~* "(nikto|sqlmap|libwww|python-urllib)") {
return 403;
}
Admin Protection¶
# Optional: IP restrict admin paths
location ~* ^/admin {
allow 10.0.0.0/8;
allow 192.168.0.0/16;
deny all;
try_files $uri /index.php?$query_string;
}
Drupal Multisite¶
server {
listen 443 ssl;
http2 on;
server_name site1.example.com site2.example.com site3.example.com;
root /var/www/drupal/web;
index index.php;
# SSL (wildcard or multi-domain cert)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Map domain to sites directory
set $site_dir default;
if ($host = "site1.example.com") { set $site_dir site1; }
if ($host = "site2.example.com") { set $site_dir site2; }
location / {
try_files $uri /index.php?$query_string;
}
location ~ '\.php$|^/update.php' {
fastcgi_split_path_info ^(.+?\.php)(|/.*)$;
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_PROXY "";
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
}
}
Performance Optimization¶
FastCGI Caching¶
fastcgi_cache_path /var/cache/nginx/drupal
levels=1:2
keys_zone=drupal:100m
inactive=60m
max_size=2g;
server {
set $skip_cache 0;
# Don't cache POST or authenticated requests
if ($request_method = POST) { set $skip_cache 1; }
if ($http_cookie ~* "SESS[a-f0-9]+|SSESS[a-f0-9]+") { set $skip_cache 1; }
# Don't cache admin or user paths
if ($request_uri ~* "^/admin|^/user|^/batch") { set $skip_cache 1; }
location ~ '\.php$|^/update.php' {
fastcgi_split_path_info ^(.+?\.php)(|/.*)$;
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_PROXY "";
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
# Caching
fastcgi_cache drupal;
fastcgi_cache_valid 200 30m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-Cache $upstream_cache_status;
}
}
Gzip/Brotli¶
# Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript
application/rss+xml application/atom+xml image/svg+xml;
# Brotli (requires module)
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css text/xml application/json application/javascript
application/rss+xml application/atom+xml image/svg+xml;
Drush Commands¶
Useful Drush commands for NGINX + Drupal:
# Clear all caches
drush cr
# Put site in maintenance mode
drush state:set system.maintenance_mode 1 --input-format=integer
# Take out of maintenance mode
drush state:set system.maintenance_mode 0 --input-format=integer
# Update database
drush updatedb
Common Issues¶
| Issue | Solution |
|---|---|
| Clean URLs not working | Ensure try_files ends with $query_string |
| Private files 403 | Check location ~ ^(/[a-z\-]+)?/system/files/ path |
| Image styles 404 | Add location ~ ^/sites/.*/files/styles/ |
| 502 on update.php | Increase fastcgi_read_timeout to 300+ |
| Aggregated CSS/JS 404 | Ensure static files try_files includes @rewrite |
Enable Drupal Logging¶
In settings.php:
$config['system.logging']['error_level'] = 'verbose';