medium10 min readLast updated May 27, 2026

Nginx Security Hardening Checklist: A Practical Configuration Guide

Harden your Nginx web server with this practical checklist. Covers version hiding, security headers, TLS configuration, rate limiting, and a complete example config.

Why Nginx hardening matters

Nginx is the most widely deployed web server on the internet. Its default configuration is designed for ease of setup, not for security. Out of the box, Nginx exposes its version number, serves default pages, allows directory listing, and lacks security headers.

Every piece of information your server reveals unnecessarily helps an attacker. Version numbers lead to targeted exploits. Default pages confirm the technology stack. Missing headers enable cross-site scripting and clickjacking.

This guide walks through each hardening step with the exact configuration directives you need.

Hide the server version

By default, Nginx includes its version number in HTTP response headers (Server: nginx/1.24.0) and on error pages. This tells attackers exactly which vulnerabilities to try.

In the http block of your nginx.conf:

http {
    server_tokens off;
}

Verify it worked:

curl -sI https://yourcompany.com | grep -i server
# Should show "Server: nginx" without a version number

For even more privacy, you can install the ngx_headers_more module and remove the Server header entirely:

more_clear_headers Server;

Remove default pages

Nginx ships with a default welcome page at /usr/share/nginx/html/index.html. If this is still accessible, it confirms you are running Nginx and suggests the server may not be fully configured.

# Remove or replace the default page
sudo rm /usr/share/nginx/html/index.html

# Or replace with a blank page
echo "" | sudo tee /usr/share/nginx/html/index.html

Also remove or disable the default server block if you are using virtual hosts:

sudo rm /etc/nginx/sites-enabled/default

Disable directory listing

If a directory does not contain an index file, Nginx can display a listing of all files. This leaks directory structure, file names, and potentially sensitive files.

Ensure autoindex is off (it is off by default, but verify):

server {
    autoindex off;
}

Also add a default index directive to serve something predictable:

server {
    index index.html index.htm;
}

Add security headers

This is one of the highest-impact steps. See our dedicated guide on HTTP security headers for a full explanation of each header. Here is the summary configuration:

# HSTS -- force HTTPS for 2 years
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# CSP -- restrict resource loading
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;

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

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

# Control referrer information
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Restrict browser features
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;

The always parameter is critical -- without it, headers are not sent on error responses (404, 500, etc.).

Configure TLS properly

Weak TLS configuration is one of the most common findings in security scans. See our full guide on weak TLS cipher suites for details. Here is the recommended configuration:

# Protocols -- TLS 1.2 and 1.3 only
ssl_protocols TLSv1.2 TLSv1.3;

# Cipher suites -- Mozilla Modern profile
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;

# Session settings
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;

# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;

Rate limiting

Rate limiting protects against brute force attacks, credential stuffing, and application-layer DDoS. Nginx has built-in rate limiting using the limit_req module.

Define a rate limit zone

In the http block:

http {
    # 10 requests per second per IP, with a 10MB shared memory zone
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;

    # Stricter limit for login endpoints
    limit_req_zone $binary_remote_addr zone=login:10m rate=2r/s;
}

Apply rate limits to locations

server {
    # General rate limit with burst allowance
    location / {
        limit_req zone=general burst=20 nodelay;
    }

    # Strict rate limit for authentication endpoints
    location /login {
        limit_req zone=login burst=5 nodelay;
        limit_req_status 429;
    }

    location /api/auth {
        limit_req zone=login burst=5 nodelay;
        limit_req_status 429;
    }
}

Custom error page for rate-limited requests

error_page 429 /429.html;
location = /429.html {
    internal;
    return 429 '{"error": "Too many requests. Please try again later."}';
}

Restrict HTTP methods

Most web applications only need GET, POST, and HEAD. Allowing other methods (PUT, DELETE, TRACE, OPTIONS) can expose vulnerabilities.

server {
    # Only allow safe methods
    if ($request_method !~ ^(GET|POST|HEAD)$) {
        return 405;
    }
}

If you run a REST API that requires PUT, DELETE, and PATCH, restrict the allowed methods per location:

location /api/ {
    # API endpoints may need more methods
    if ($request_method !~ ^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$) {
        return 405;
    }
}

location / {
    # Static content only needs GET and HEAD
    if ($request_method !~ ^(GET|HEAD)$) {
        return 405;
    }
}

Log configuration for security monitoring

Good logging is essential for detecting and investigating attacks.

Access log format with security-relevant fields

http {
    log_format security '$remote_addr - $remote_user [$time_local] '
                        '"$request" $status $body_bytes_sent '
                        '"$http_referer" "$http_user_agent" '
                        '$request_time $upstream_response_time '
                        '$http_x_forwarded_for';

    access_log /var/log/nginx/access.log security;
    error_log /var/log/nginx/error.log warn;
}

Log rate-limited requests separately

# Log rate-limited requests for monitoring
limit_req_log_level warn;

Rotate logs

Ensure logrotate is configured for Nginx logs to prevent disk space issues:

cat /etc/logrotate.d/nginx

If it does not exist, create it:

/var/log/nginx/*.log {
    daily
    missingok
    rotate 52
    compress
    delaycompress
    notifempty
    create 0640 www-data adm
    sharedscripts
    postrotate
        [ -f /var/run/nginx.pid ] && kill -USR1 `cat /var/run/nginx.pid`
    endscript
}

Additional hardening

Limit request body size

Prevent excessively large uploads that could exhaust disk space or memory:

client_max_body_size 10m;

Set timeouts to prevent slowloris attacks

client_body_timeout 10s;
client_header_timeout 10s;
send_timeout 10s;
keepalive_timeout 15s;

Block access to hidden files

Prevent access to .git, .env, .htaccess, and other dotfiles:

location ~ /\. {
    deny all;
    access_log off;
    log_not_found off;
}

Block access to sensitive file extensions

location ~* \.(bak|config|sql|fla|psd|ini|log|sh|inc|swp|dist|env)$ {
    deny all;
    access_log off;
    log_not_found off;
}

Complete hardened nginx.conf example

Here is a complete server block incorporating all the above:

server {
    listen 443 ssl http2;
    server_name yourcompany.com www.yourcompany.com;

    # TLS
    ssl_certificate /etc/letsencrypt/live/yourcompany.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourcompany.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_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 8.8.8.8 valid=300s;

    # Hide version
    server_tokens off;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;

    # Rate limiting
    limit_req zone=general burst=20 nodelay;

    # Restrict methods
    if ($request_method !~ ^(GET|POST|HEAD)$) {
        return 405;
    }

    # Block dotfiles
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }

    # Block sensitive extensions
    location ~* \.(bak|config|sql|fla|psd|ini|log|sh|inc|swp|dist|env)$ {
        deny all;
    }

    # Timeouts
    client_body_timeout 10s;
    client_header_timeout 10s;
    send_timeout 10s;
    keepalive_timeout 15s;
    client_max_body_size 10m;

    # Disable autoindex
    autoindex off;

    # Root and index
    root /var/www/yourcompany.com;
    index index.html;

    # Logging
    access_log /var/log/nginx/yourcompany.access.log security;
    error_log /var/log/nginx/yourcompany.error.log warn;
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name yourcompany.com www.yourcompany.com;
    return 301 https://$host$request_uri;
}

After applying, test and reload:

sudo nginx -t
sudo systemctl reload nginx

Then verify with Security Headers and SSL Labs to confirm everything is in place. For more on your broader network exposure, review our guide on open ports security.

How SurfaceScan helps

SurfaceScan tests your Nginx servers (and other web servers) from the outside on every scan. It detects exposed version numbers, missing security headers, weak TLS configuration, and other hardening gaps. Each finding includes the specific issue, the affected URL, and the configuration change needed to fix it -- so you can validate your hardening against what the internet actually sees.

Related articles