Skip to main content

Hardening Nginx: Secure Web Server Configuration

Security · 19.04.2026 · 6 min read
Illustration for “Hardening Nginx: Secure Web Server Configuration”

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; }
}
Use securityheaders.com to analyze HTTP headers. The goal is an A or A+ rating.
CSP (Content Security Policy) — the most powerful header — requires careful configuration. An incorrect CSP will break the site. Start with 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
MessageCauseFix
invalid number of arguments in "add_header"The header value is missing quotes, so Nginx split the line into several arguments on whitespaceWrap the entire header value in double quotes, including parts like max-age=31536000
conflicting server name on 443The same server_name is declared in two server blocks listening on the same portCheck with nginx -T | grep -A2 server_name, keep the declaration in only one file
403 Forbidden after limit_exceptThe request method (often PUT or PATCH from an API client) isn't in the allowed listAdd the needed method to limit_except GET HEAD POST only where it's actually used
"ssl_ciphers" directive is duplicateThe 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

MeasureRisk it closesImplementation effort
server_tokens off and protective headersServer version disclosure, clickjacking, MIME-sniffingLow, safe for any site
Rate limiting on login and APIPassword brute-forcing, endpoint enumerationMedium, limits need tuning to real traffic
Blocking hidden and service filesLeaking .env, .git, .sql backups via a direct URLLow, but easy to forget a new extension when adding services
Content-Security-PolicyXSS via injected third-party JSHigh, 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
  1. Run nginx -t before every reload — a syntax error in production takes down the whole site, not just the directive being applied
  2. Request the headers with curl -I and confirm X-Frame-Options, X-Content-Type-Options, and Strict-Transport-Security are actually present in the response
  3. Check that the Server header doesn't reveal the Nginx version — confirmation that server_tokens off took effect
  4. 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.

Was this article helpful?
← Back to Knowledge Base Ask Support