The default Nginx installation exposes the server version and doesn't enable protective HTTP headers. Hardening takes 20 minutes and closes an entire class of vulnerabilities.
nginx.conf — basic settings
http {
server_tokens off; # Hide the version
client_max_body_size 10m;
client_body_timeout 12;
client_header_timeout 12;
keepalive_timeout 15;
large_client_header_buffers 2 1k;
}
Protective HTTP 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;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
Rate Limiting
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
}
server {
location /api/ { limit_req zone=api burst=20 nodelay; }
location /wp-login.php { limit_req zone=login burst=3; }
}
Blocking hidden and dangerous files
location ~ /\. { deny all; access_log off; log_not_found off; }
location ~* \.(bak|conf|sql|log|sh|ini)$ { deny all; }
SSL/TLS configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
Restricting HTTP methods
location / {
limit_except GET HEAD POST { deny all; }
}
Content-Security-Policy-Report-Only before deploying it in production.
Common Errors After Hardening
nginx: [emerg] invalid number of arguments in "add_header" directive
nginx: [warn] conflicting server name "example.com" on 0.0.0.0:443
403 Forbidden after enabling limit_except
nginx: [emerg] "ssl_ciphers" directive is duplicate
| Message | Cause | Fix |
|---|---|---|
| invalid number of arguments in "add_header" | The header value is missing quotes, so Nginx split the line into several arguments on whitespace | Wrap the entire header value in double quotes, including parts like max-age=31536000 |
| conflicting server name on 443 | The same server_name is declared in two server blocks listening on the same port | Check with nginx -T | grep -A2 server_name, keep the declaration in only one file |
| 403 Forbidden after limit_except | The request method (often PUT or PATCH from an API client) isn't in the allowed list | Add the needed method to limit_except GET HEAD POST only where it's actually used |
| "ssl_ciphers" directive is duplicate | The SSL directive is set both in http {} and again in a specific server {} | Keep the directive in one place — usually at the http level, if certificates and requirements are the same for every site |
Comparing Hardening Levels
| Measure | Risk it closes | Implementation effort |
|---|---|---|
| server_tokens off and protective headers | Server version disclosure, clickjacking, MIME-sniffing | Low, safe for any site |
| Rate limiting on login and API | Password brute-forcing, endpoint enumeration | Medium, limits need tuning to real traffic |
| Blocking hidden and service files | Leaking .env, .git, .sql backups via a direct URL | Low, but easy to forget a new extension when adding services |
| Content-Security-Policy | XSS via injected third-party JS | High, requires inventorying every external script on the site |
When This Hardening Isn't Enough
- The attack goes through a vulnerability in the application itself (SQL injection, unsafe deserialization) — Nginx headers don't close that, you need a code patch or an application-level WAF
- You need protection against distributed brute-forcing from thousands of IPs — rate limiting by a single address is powerless here; that needs behavior analysis at the CDN/proxy layer in front of the server
- The site embeds third-party widgets (chat, analytics, payment forms) — a strict CSP without a Report-Only period is almost guaranteed to break some of them the first time it's turned on
- You need to protect data moving between internal services outside of HTTP — the hardening in this article only covers public web traffic, not the internal network
How to Verify the Protection Actually Works
# Config syntax before applying on the production server
sudo nginx -t
# Server response headers
curl -sI https://yourdomain.com/ | grep -Ei 'x-frame|x-content|strict-transport|referrer'
# The server version isn't leaked in the header or on service pages
curl -sI https://yourdomain.com/ | grep -i server
- Run
nginx -tbefore everyreload— a syntax error in production takes down the whole site, not just the directive being applied - Request the headers with
curl -Iand confirm X-Frame-Options, X-Content-Type-Options, and Strict-Transport-Security are actually present in the response - Check that the
Serverheader doesn't reveal the Nginx version — confirmation thatserver_tokens offtook effect - Run the site through an external header-checking service (such as the one mentioned in the tip above) and aim for a grade of A or better, starting CSP in Report-Only mode
A separate breakdown of rate limiting and connection limits is in the article Fail2ban for SSH and Nginx, and setting up a baseline firewall in front of the web server is covered in UFW on a VPS.
Frequently asked questions
What should you do if legitimate frontend requests start failing en masse after adding rate limiting on /api/?
Check whether the limit sits below the real number of AJAX calls a single page fires — a zone of rate=10r/s with burst=20 can be too tight for an SPA sending dozens of requests at once on load. Raise burst or add nodelay only after analyzing the logs, not by guessing, or you risk weakening the protection for nothing.
Why does a page with CSP set to Report-Only still break for some visitors?
Report-Only only logs policy violations, it does not block resources from loading, so it cannot break anything by itself. The breakage almost always traces back to a different header or to a browser or CDN cache serving a stale version of the page. Clear the cache and compare headers with curl first, before touching any CSP directives.
Do security headers need to be repeated manually for every virtual host?
No, directives like add_header and ssl_ciphers are better defined once in a shared config and pulled into every server block with include. Repeating the same lines in every host file quickly causes drift: one site gets updated, the rest get forgotten, and the protection level ends up uneven with no single place to fix it.
How do you confirm restricting HTTP methods hasn't broken an API that relies on PUT or DELETE?
Check the logs for 403 responses right after applying limit_except and replay the client's actual requests through curl with the needed method. If an API on other paths legitimately uses PUT, PATCH, or DELETE, scope the restriction to specific location blocks instead of the whole site — otherwise the server rejects anything beyond GET, HEAD, and POST.