How to Harden Nginx: A Practical 2026 Guide

Server tokens, TLS config, security headers, rate limiting, bot blocking , the full Nginx hardening stack with current syntax and the OCSP stapling warning most guides still get wrong.

Share
Dark terminal showing two-column nginx config diff: deprecated ssl_stapling directive crossed out in red, correct config in green

A default nginx install is functional. It's also fairly loud about what it is, doesn't send a single security header, and will happily serve every request a scanner throws at it until the machine buckles.

This guide covers the full stack: version disclosure, TLS, security headers, rate limiting, and bot blocking. There's also one important warning about OCSP stapling that most current guides still get wrong.

Hide the Version Number (and optionally the Server Header)

By default, nginx announces its version number in response headers and on error pages. A response might show Server: nginx/1.30.5. One line in the http{} block stops that:

server_tokens off;

After this, the header reads Server: nginx, no version. That's good enough for most sites. The version is gone; an attacker still knows you're running nginx, but they can't immediately target a known CVE for a specific release.

If you want the Server header gone entirely, that requires the headers-more module. On Debian/Ubuntu, apt install nginx-extras pulls it in. Then in http{}:

more_clear_headers Server;

Whether full header removal is worth the extra dependency is a judgment call. For a personal project or a low-profile site, server_tokens off is fine. For anything that gets scanned heavily, removing the header entirely reduces noise.

TLS Configuration

This is the Intermediate profile from the current TLS reference, which is the right choice for most production sites, it supports TLS 1.2 and 1.3, drops all legacy cipher suites, and maintains broad browser compatibility. Add this to your http{} block or the relevant server{} block:

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;
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_dhparam /etc/nginx/dhparam.pem;

Three things in here are worth explaining, because older guides get all three wrong.

ssl_prefer_server_ciphers off, older hardening guides typically say to set this to on. Current guidance says off for the Intermediate profile, letting clients negotiate the strongest mutual cipher they support. Trust the client to pick well.

ssl_session_tickets off, session tickets are an optimization for resuming TLS sessions, but they weaken forward secrecy if the ticket key isn't rotated regularly. Unless you've set up key rotation, off is safer.

The cipher list no longer includes DHE-RSA-*. If you copied a config from an older guide, it probably ends with three DHE-RSA ciphers, DHE-RSA-AES128-GCM-SHA256, DHE-RSA-AES256-GCM-SHA384, DHE-RSA-CHACHA20-POLY1305. Those were dropped from the Intermediate profile. They're more computationally expensive than their ECDHE equivalents, and they're more vulnerable to resource-exhaustion attacks such as the D(HE)at attack (CVE-2002-20001). Removing them costs you nothing: every client that supported them also supports ECDHE.

Use the standard ffdhe2048 group instead of generating your own DH parameters. The old advice was to run openssl dhparam -out /etc/nginx/dhparam.pem 2048, which takes a minute or two of CPU on the server. Current guidance specifies the RFC 7919 named group, which is audited and precomputed. In practice that means either dropping the ssl_dhparam directive entirely and letting OpenSSL use ffdhe2048, or pointing it at the system-provided group:

ssl_dhparam /etc/ssl/ffdhe2048.pem;

Check whether your distribution already ships that file before generating anything.

Note the X25519MLKEM768 curve at the front of ssl_ecdh_curve. That's a post-quantum hybrid key exchange, and it's in the current recommendation, nginx 1.30.x supports it. If your build rejects the directive, drop it back to X25519:prime256v1:secp384r1; the rest of the config is unaffected.

The OCSP Stapling Warning

If you're using Let's Encrypt, do not add ssl_stapling on. Let's Encrypt shut down its OCSP responder on August 6, 2025. OCSP URLs were removed from new certificates starting May 7, 2025. Adding the stapling directives to an LE config produces nginx warnings at reload and does nothing useful.

OCSP stapling still works if you're using a CA that still operates an OCSP responder. Let's Encrypt isn't one of them anymore.

Security Headers

These go in the server{} block. The always keyword is not optional, without it, headers are only sent on 2xx and 3xx responses. Error pages (which are exactly when you want these headers) won't get them.

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" 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=(), usb=()" always;
add_header X-XSS-Protection "0" always;

The X-XSS-Protection: 0 line looks wrong. It isn't. The old 1; mode=block value had a documented security flaw in Internet Explorer, it could be exploited to create XSS vulnerabilities rather than block them. Modern browsers either ignore the header entirely or have their own built-in XSS protections that supersede it. Setting it to 0 explicitly disables any broken legacy behavior in clients that still check it. The TLS reference and Mozilla both document this, and the recommendation is zero.

Adjust Permissions-Policy for your actual needs. If your site uses geolocation, remove that restriction. The list above is a conservative starting point.

Rate Limiting

The two directives work as a pair: limit_req_zone handles request rate; limit_conn_zone handles simultaneous connections. Both go in the http{} block. The actual enforcement goes in server{} or location{}.

# http{} block
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;

$binary_remote_addr stores the IP as 4 or 16 bytes instead of a variable-length string, more memory-efficient than $remote_addr for the shared zone. The 10m allocates 10MB of shared memory, enough to track roughly 160,000 IPs.

Then in your server{} block:

location / {
    limit_req zone=general burst=20 nodelay;
    limit_req_status 429;
}

location /wp-login.php {
    limit_req zone=login burst=5 nodelay;
    limit_req_status 429;
}

limit_conn conn_per_ip 20;

burst=20 nodelay allows short spikes, a user loading a page that hits 12 assets simultaneously won't get throttled. nodelay processes the burst immediately rather than queuing requests, which avoids long response delays for legitimate users. The limit_req_status 429 returns the RFC-correct status code for rate limiting; the default is 503, which is technically wrong and can confuse monitoring tools.

Tune the rate and burst values for your traffic patterns. These are conservative defaults, a busy site with authenticated users may need to raise them.

Bot Blocking

The simplest approach requires no extra modules, just a map block and a conditional. Add this to http{}:

map $http_user_agent $bad_bot {
    default         0;
    ~*masscan       1;
    ~*zgrab         1;
    ""              1;
}

Then in your server{} block:

if ($bad_bot) { return 444; }

Return code 444 is nginx-specific, it closes the connection without sending a response. Scanners get nothing; they can't even confirm the server is running nginx. The empty user agent match ("") catches a significant chunk of automated scanners that don't bother setting one.

This is functional but manual. If you'd rather have a maintained list, nginx-ultimate-bad-bot-blocker ships roughly 700 bad user-agents and 7,100 bad referrers, and updates on a rolling basis. It uses the same map directive approach, just with a much longer list and an auto-update script. Worth considering if you're running a site that gets regular scanner traffic, but check the current counts on the repo rather than trusting a number quoted in a guide, including this one.

If you went through the first 24 hours on a new VPS, you've already seen what those scan logs look like. This is how you stop them from wasting nginx worker cycles.

The Optional WAF Layer

ModSecurity adds a full application firewall on top of nginx, traffic is inspected against the OWASP Core Rule Set before it hits your application. Real protection, real cost.

One distinction that matters: the commercial NGINX Plus ModSecurity WAF module reached EOL on March 31, 2024. If you've seen guides referencing that module, they're describing something that's no longer supported. The open-source path is libmodsecurity3 combined with the ModSecurity-nginx connector, which is actively maintained.

ModSecurity is not the only option, and it's not the lightest. Open-appsec is a machine-learning-based WAF that attaches to nginx as a module, ships with a sensible default policy, and needs far less rule tuning to be useful, it works out what normal traffic to your site looks like rather than making you curate a ruleset. I walked through the install in Installing Open-appsec for Linux on Nginx. If you've never run a WAF before and the OWASP CRS rule-tuning curve looks unappealing, that's the path I'd start with.

The latency hit for either is roughly 1–5ms per request depending on which rules you enable and what your hardware looks like. For a site that handles user-submitted input or runs e-commerce, that's a reasonable trade. For a static blog or documentation site, it's probably not worth the operational overhead.

Layering With More Security Tools

Nginx hardening covers the web layer. The firewall layer (restricting which ports are even reachable) is handled by UFW, which I covered in the first 24 hours on a new VPS. For active threat intelligence and IP reputation blocking at the system level, CrowdSec integrates directly with nginx via a bouncer and handles bans automatically based on reported attacker IPs across the CrowdSec network.

These aren't redundant, they operate at different layers. UFW controls port access before a TCP connection completes. Nginx rate limiting and bot blocking operate once a connection reaches nginx. CrowdSec drops repeat offenders before they touch the stack at all.

The full picture is in the server hardening checklist if you want a single reference that covers all three layers together.

## Convertkit Newsletter