Making Your Nginx Website Safer, Step by Step

Five things to do after installing Nginx: security headers, blocking sensitive files, HTTPS, login rate limits and the fake-IP trap via X-Forwarded-For, all built and tested.

Phát LâmPublished Updated 6 min readIntermediate
Making Your Nginx Website Safer, Step by Step
On this page
  1. Step 1: tell browsers to tighten up (security headers)
  2. Step 2: block files that should not be public
  3. Step 3: HTTPS settings
  4. Step 4: limit request speed to stop password guessing
  5. Step 5: do not trust the IP the visitor claims (X-Forwarded-For)
  6. Mistakes I keep running into
  7. What to do next

You install Nginx and the site runs fine. A few weeks later someone types yourdomain.com/.env and downloads the file holding your database password. This happens to real websites, because Nginx's defaults are built to get a site running, not to keep it safe. This post collects what I always do when setting up a web server, easiest steps first.

Every configuration below I built on a standalone Nginx 1.30 on a test machine (local ports, its own directory, nothing touching the running server) and checked with curl and openssl. These are general examples. Adjust them to your app and try them on a test environment first.

Layered shields around a server, clean requests passing through while bad ones are blocked with amber sparks
Defense in depth: headers, TLS, rate limiting and file filtering.

Step 1: tell browsers to tighten up (security headers)

Security headers are a few lines the server sends with every page, asking the visitor's browser to be more careful. I keep them in one shared file:

# snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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=()" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" always;

In plain words, the five lines do this. Strict-Transport-Security makes the browser use only HTTPS for this domain for a year. X-Content-Type-Options stops the browser guessing file types, so an uploaded file is not run as a script. Referrer-Policy limits how much of the page address is sent to other sites. Permissions-Policy switches off camera, microphone and location, which the page does not use. Content-Security-Policy (CSP) is a list of places allowed to load scripts, images and styles, which cuts the damage sharply if an attacker manages to inject code into a page.

Some lessons from practice. The word always makes headers go out even on error pages. For CSP, start with Content-Security-Policy-Report-Only to see what would be blocked before blocking anything, and avoid 'unsafe-inline' for scripts. Turn on HSTS only after HTTPS works across the domain and all subdomains, because browsers remember it for a long time. And do not add X-XSS-Protection, which modern browsers dropped.

The trap I see most is here: Nginx inherits add_header from the outer level only when the current level has no add_header of its own. A single location that adds add_header Cache-Control ... makes all five security headers vanish from that path. I tested it on the real build: /static/, with its own add_header, returned 0 security headers; /static-ok/, which includes the shared file again, returned them all. The cure is include snippets/security-headers.conf; inside every location that has its own add_header.

Step 2: block files that should not be public

location ~ /\.(?!well-known/) { deny all; }
location ~* ^/(composer\.(json|lock)|package(-lock)?\.json)$ { deny all; }
location ~* \.(sql|bak|old|orig|swp|log)$ { deny all; }

The first line blocks every hidden file such as .env and .git, but spares /.well-known/, which HTTPS certificate requests need. I tested with curl: /.env, /.git/HEAD, /composer.json and /backup.sql all returned 403, a file under /.well-known/ returned 200, the home page 200, and a missing path 404. Add server_tokens off; so the server does not announce its version, and set limits on request size and wait times so slow connections cannot hold a seat forever.

Step 3: HTTPS settings

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_tickets off;

Keep only TLS 1.2 and 1.3 and drop the two old versions. I confirmed nginx -t passes and did a trial handshake with openssl s_client: TLS 1.2 picked ECDHE-RSA-AES256-GCM-SHA384, and TLS 1.3 picked TLS_AES_256_GCM_SHA384. Take the cipher list for TLS 1.2 from Mozilla's configuration generator, since recommendations change over time. With Let's Encrypt certificates you no longer need OCSP stapling, because they ended their OCSP service in 2025 (I did not test that part, since it needs a real certificate).

Step 4: limit request speed to stop password guessing

limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_status 429;

server {
  location = /login {
    limit_req zone=login burst=2 nodelay;
    proxy_pass http://app;
  }
}

rate=5r/m means 5 requests per minute per IP address, burst=2 allows 2 extra, and anything beyond gets a 429. I called it 6 times in a row and saw 200 200 200 429 429 429. One small trap while testing: limit_req does nothing inside a location that only has return, because return runs first. My first attempt gave six 200s for exactly that reason. Put the limit in a location that has proxy_pass or serves files.

Step 5: do not trust the IP the visitor claims (X-Forwarded-For)

Many apps behind Nginx read the visitor's IP from the X-Forwarded-For header, usually taking the first value because they assume that is the visitor. But the visitor can send this header. The common setting proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; only appends the real IP to whatever the visitor sent. An attacker sends X-Forwarded-For: 1.2.3.4, the app receives "1.2.3.4, <real IP>" and takes 1.2.3.4. Change the value on every request and each one looks like a new visitor, so any rate limit inside the app does nothing.

I rebuilt exactly this flaw: a small app allowing 3 requests per IP, reading the IP from the first value of the header, placed behind Nginx. I sent 6 requests, each with a different header:

Wrong setup (append to the header):   200 200 200 200 200 200   <- bypassed
Right setup (overwrite with real IP): 200 200 200 429 429 429

When Nginx is the outermost layer, the right setting is to overwrite:

location / {
  proxy_pass http://127.0.0.1:3000;
  proxy_set_header X-Real-IP       $remote_addr;
  proxy_set_header X-Forwarded-For $remote_addr;
}

Even better, let Nginx itself limit by $binary_remote_addr as in step 4, because that value comes from the real connection and cannot be faked.

If Nginx sits behind Cloudflare or a load balancer, $remote_addr is the proxy's own IP. In that case use the realip module and trust the header only from proxy addresses you list:

set_real_ip_from 127.0.0.1;        # list your own proxy ranges
real_ip_header X-Forwarded-For;
real_ip_recursive on;

I checked three cases. A request from a trusted proxy carrying X-Forwarded-For: 203.0.113.7 made $remote_addr become 203.0.113.7. A request from an untrusted source with the same header had it ignored, and changing the value six times still gave 200 200 200 429 429 429. With X-Forwarded-For: 6.6.6.6, 198.51.100.9 and real_ip_recursive on, the result was 198.51.100.9: the fake part at the front is skipped. A common mistake here is set_real_ip_from 0.0.0.0/0 or any range that is too wide, which lets anyone fake an IP.

A throttling gate letting requests through steadily, excess requests bouncing back even with a forged label
Limit by the real connection IP and a forged label does nothing.

Mistakes I keep running into

I often see someone add one add_header Cache-Control for convenience and forget that the security headers just disappeared. I also see HSTS switched on while one subdomain still has no HTTPS, locking that subdomain out. A CSP of default-src * is as good as none. And after every config change, run nginx -t before reload, because one missing semicolon is enough to take the site down.

What to do next

Run curl -I against a few different pages to confirm all five headers appear everywhere, try downloading /.env and /.git/HEAD to see 403, then set rate limits on the login and password reset pages. You can automate those checks with a daily script, like the one in Self-Check Your Website's Security with the OWASP Top 10 (2025). Once the hardening is done and you want to look from an outsider's side, follow the permitted process in Testing Your Own Website's Defenses With Permission: The Basics.

Web hardeningBảo mật full-stacknginxhardeningcsphstsrate-limitx-forwarded-fortls

References

  1. Nginx - ngx_http_headers_module (add_header, quy tắc kế thừa)
  2. Nginx - ngx_http_limit_req_module
  3. Nginx - ngx_http_realip_module
  4. Nginx - ngx_http_ssl_module
  5. MDN - Content-Security-Policy
  6. MDN - Strict-Transport-Security
  7. Mozilla SSL Configuration Generator
  8. Let's Encrypt - Ending OCSP Support in 2025
  9. MDN - X-Forwarded-For (cảnh báo tin phần tử đầu)