This guide provides comprehensive security hardening recommendations for NGINX with actionable configuration examples.
Where possible, bind NGINX to internal interfaces only:
# Listen on localhost only
server {
listen 127.0.0.1:80;
listen 127.0.0.1:443 ssl http2;
}
# Or specific internal IP
server {
listen 192.168.1.10:80;
listen 192.168.1.10:443 ssl http2;
}
Create a default server block to reject requests with unknown Host headers:
# Default catch-all for HTTP
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
access_log /var/log/nginx/unknown-host-access.log;
error_log /var/log/nginx/unknown-host-error.log;
return 444; # NGINX non-standard code: close connection without response
}
# Default catch-all for HTTPS
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
server_name _;
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
access_log /var/log/nginx/unknown-host-ssl-access.log;
error_log /var/log/nginx/unknown-host-ssl-error.log;
return 444;
}
http {
# TLS 1.2 and TLS 1.3 only
ssl_protocols TLSv1.2 TLSv1.3;
# Modern cipher suite (TLS 1.3 only supports secure AEAD ciphers)
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;
# ECDH curves
ssl_ecdh_curve X25519:prime256v1:secp384r1;
# Session settings (TLS 1.2 compatibility)
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# DH parameters (generate with: curl https://ssl-config.mozilla.org/ffdhe2048.txt -o /etc/nginx/dhparam.pem)
ssl_dhparam /etc/nginx/dhparam.pem;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com;
# Certificate files
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
# OCSP Stapling
# Note: Verify OCSP endpoint availability with your CA
ssl_stapling on;
ssl_stapling_verify on;
resolver 127.0.0.53 valid=300s;
resolver_timeout 5s;
root /var/www/example.com;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
# Download pre-generated DH parameters (Mozilla)
sudo curl https://ssl-config.mozilla.org/ffdhe2048.txt -o /etc/nginx/dhparam.pem
sudo chmod 644 /etc/nginx/dhparam.pem
# Or generate your own (takes ~5-10 minutes for 2048-bit)
sudo openssl dhparam -out /etc/nginx/dhparam.pem 2048
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# Redirect all HTTP traffic to HTTPS
return 301 https://$host$request_uri;
}
# Add to server block or location
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
HSTS Parameters:
| Parameter | Value | Meaning |
|---|---|---|
max-age |
63072000 | Browser remembers HTTPS-only for 2 years |
includeSubDomains |
– | Applies to all subdomains |
preload |
– | Eligible for browser preload lists |
always |
– | Send header even on error responses |
Prevent information disclosure by hiding NGINX version:
http {
# Hide NGINX version in error pages and Server header
server_tokens off;
# For additional hiding, use headers-more module
# more_clear_headers Server;
}
Verification:
curl -I https://example.com
# Should NOT show NGINX version in Server header
Prevent directory enumeration attacks:
server {
location / {
# Disable autoindex (directory listing)
autoindex off;
# Or explicitly enable for specific directories
# autoindex on;
# autoindex_exact_size off;
# autoindex_localtime on;
}
}
Check loaded modules:
nginx -V 2>&1 | grep -o '\-\-with-[a-z_]*' | sort
Modules to consider disabling (recompile without if possible):
ngx_http_autoindex_module - Directory listingngx_http_ssi_module - Server-side includes (potential injection risk)ngx_http_dav_module - WebDAV (if not needed)Disable in configuration:
http {
# Disable SSI
ssi off;
# Disable DAV methods
dav_methods off;
}
# Remove default site
sudo rm /etc/nginx/sites-enabled/default
# Or
sudo rm /etc/nginx/conf.d/default.conf
# Remove default welcome page
sudo rm /usr/share/nginx/html/index.html
# Block access to hidden files (dotfiles)
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Block specific sensitive file patterns
location ~* \.(git|svn|htaccess|htpasswd|env|config|sql|log|bak|backup)$ {
deny all;
access_log off;
log_not_found off;
}
# Block version control directories
location ~ /\.git {
deny all;
return 404;
}
# Restrict admin directory to specific IPs
location /admin {
allow 192.168.1.100;
allow 192.168.1.101;
allow 10.0.0.0/8;
deny all;
auth_basic "Admin Area";
auth_basic_user_file /etc/nginx/.htpasswd;
}
# Restrict server status to localhost
location /nginx_status {
stub_status on;
allow 127.0.0.1;
allow ::1;
deny all;
access_log off;
}
# Block access to development directories
location ~* ^/(test|dev|staging) {
deny all;
return 403;
}
# Allow only necessary HTTP methods
location / {
limit_except GET HEAD POST {
deny all;
}
}
# API endpoint with specific methods
location /api/ {
limit_except GET POST PUT DELETE OPTIONS {
deny all;
}
}
# Block TRACE method (security risk)
if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE|OPTIONS)$) {
return 405;
}
http {
# Define geo block for country-based access
geo $blocked_country {
default 0;
# Block specific countries (requires geoip module)
# 1.0.0.0/24 1;
}
# Define allowed IPs
geo $allowed_ip {
default 0;
192.168.1.0/24 1;
10.0.0.0/8 1;
}
# Map for combined check
map $blocked_country$allowed_ip $access_allowed {
default yes;
10 no; # Blocked country
00 no; # Not allowed IP (when needed)
}
}
server {
location /admin {
if ($access_allowed = "no") {
return 403;
}
}
}
Add comprehensive security headers to all responses:
http {
# Use map to set headers once for all server blocks
map $sent_http_content_type $x_content_type_options {
default "nosniff";
}
server {
# Prevent clickjacking
add_header X-Frame-Options "DENY" always;
# Prevent MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;
# XSS protection (modern browsers ignore this, use CSP instead)
add_header X-XSS-Protection "0" always;
# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
# HTTP Strict Transport Security
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Referrer Policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions Policy (formerly Feature-Policy)
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
}
}
Header Values Explained:
| Header | Recommended Value | Purpose |
|---|---|---|
X-Frame-Options |
DENY |
Prevents clickjacking attacks |
X-Content-Type-Options |
nosniff |
Prevents MIME-type sniffing |
X-XSS-Protection |
0 |
Disables legacy XSS filter (use CSP instead) |
Content-Security-Policy |
See above | Controls resource loading |
Strict-Transport-Security |
max-age=63072000 |
Enforces HTTPS for 2 years |
Referrer-Policy |
strict-origin-when-cross-origin |
Controls referrer information |
Permissions-Policy |
See above | Disables browser features |
http {
# Maximum concurrent connections per worker
worker_connections 1024;
# Maximum requests per connection
keepalive_requests 100;
# Keep-alive timeout
keepalive_timeout 65;
}
http {
# Define rate limit zones (in http context)
limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
location / {
# Apply rate limiting
limit_req zone=one burst=20 nodelay;
limit_conn addr 10;
}
location /api/ {
# Stricter rate limiting for API
limit_req zone=api burst=10 nodelay;
}
}
}
http {
# Maximum request body size
client_max_body_size 10M;
# Buffer sizes
client_body_buffer_size 128k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
# Timeouts
client_body_timeout 10s;
client_header_timeout 10s;
send_timeout 10s;
}
http {
# Require client to send complete headers within time limit
client_header_timeout 10s;
# Require client to send complete body within time limit
client_body_timeout 10s;
# Minimum bytes per second for client request
client_body_buffer_size 1k;
# Close connections with slow requests
send_timeout 10s;
}
NGINX should run as an unprivileged user:
# In nginx.conf
user nginx nginx;
# Or on Debian/Ubuntu
user www-data www-data;
Create dedicated user if needed:
sudo useradd -r -s /usr/sbin/nologin nginx
sudo groupadd nginx
# Configuration files (readable by nginx user)
sudo chown -R root:nginx /etc/nginx
sudo chmod -R 750 /etc/nginx
sudo chmod 640 /etc/nginx/nginx.conf
# Log files
sudo chown -R nginx:adm /var/log/nginx
sudo chmod 755 /var/log/nginx
sudo chmod 644 /var/log/nginx/*.log
# Web root
sudo chown -R www-data:www-data /var/www/html
sudo chmod -R 755 /var/www/html
sudo find /var/www/html -type f -exec chmod 644 {} \;
# SSL certificates
sudo chown root:root /etc/ssl/private/*.key
sudo chmod 600 /etc/ssl/private/*.key
sudo chown root:root /etc/ssl/certs/*.crt
sudo chmod 644 /etc/ssl/certs/*.crt
http {
# Don't follow symbolic links
disable_symlinks on;
# Or check if owner matches
# disable_symlinks if_not_owner;
}
# In nginx.conf
worker_processes auto; # Match CPU cores
worker_rlimit_nofile 65535; # Increase file descriptor limit
events {
worker_connections 4096; # Connections per worker
multi_accept on; # Accept multiple connections at once
use epoll; # Linux: use epoll
}
Enable comprehensive logging for security monitoring:
http {
# Custom log format with security-relevant fields
log_format security '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'"$http_x_forwarded_for" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
# Access log
access_log /var/log/nginx/access.log security;
# Error log with appropriate level
error_log /var/log/nginx/error.log warn;
# Separate logs for virtual hosts
server {
access_log /var/log/nginx/example.com-access.log security;
error_log /var/log/nginx/example.com-error.log warn;
}
}
Log rotation (/etc/logrotate.d/nginx):
/var/log/nginx/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 640 nginx adm
sharedscripts
postrotate
systemctl reload nginx > /dev/null 2>&1 || true
endscript
}
Always test configuration before applying:
# Test configuration syntax
sudo nginx -t
# Expected output:
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful
Reload NGINX to apply hardening configuration:
# Reload (graceful, no downtime)
sudo systemctl reload nginx
# Or
sudo nginx -s reload
# Check status
sudo systemctl status nginx
# View logs for errors
sudo journalctl -u nginx -f
# Or
sudo tail -f /var/log/nginx/error.log
After applying hardening, verify:
server_tokens off;)autoindex off;).git, .env, .htaccess)Verify your hardening with these tools:
| Tool | Purpose |
|---|---|
| SSL Labs | TLS/SSL configuration test |
| SecurityHeaders.com | Security headers analysis |
| Mozilla Observatory | Comprehensive security scan |
curl -I https://example.com |
Manual header inspection |
nginx -t |
Configuration syntax test |
openssl s_client -connect example.com:443 |
TLS connection test |
Test security headers:
curl -I https://example.com
Expected output should include:
HTTP/2 200
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-frame-options: DENY
x-content-type-options: nosniff
content-security-policy: default-src 'self'
referrer-policy: strict-origin-when-cross-origin
Test TLS configuration:
# Test TLS 1.2
openssl s_client -connect example.com:443 -tls1_2
# Test TLS 1.3
openssl s_client -connect example.com:443 -tls1_3
# Verify TLS 1.0/1.1 are disabled
openssl s_client -connect example.com:443 -tls1_1
# Should fail with "no protocols available"
Verify server tokens:
curl -I https://example.com | grep -i server
# Should show: Server: nginx (no version number)
# Or with headers-more: Server header removed entirely
Prefer automation? See NGINX Ansible Setup for an example playbook.
Prefer containers? See NGINX Docker Setup.
Any questions?
Feel free to contact us. Find all contact information on our contact page.