---
title: "Making Your Nginx Website Safer, Step by Step"
url: https://phatdev.net/en/blog/nginx-web-hardening
lang: en
updated: 2026-10-21
alternate:
  vi: https://phatdev.net/vi/blog/nginx-web-hardening
markdown: https://phatdev.net/en/blog/nginx-web-hardening.md
---

# 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.

Categories: Security | Published: 2026-10-19 | Level: Intermediate | 6 min read

Tags: nginx, hardening, csp, hsts, rate-limit, x-forwarded-for, tls

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.

![Defence layers around a server: headers, TLS, rate limiting, file filtering](https://phatdev.net/uploads/blog/nginx-web-hardening/layers-1920.webp)

## 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:

```nginx
# 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

```nginx
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

```nginx
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

```nginx
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:

```nginx
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:

```nginx
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 gate that throttles requests; extra requests bounce back even when they carry a fake label](https://phatdev.net/uploads/blog/nginx-web-hardening/ratelimit-1920.webp)

## 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)](https://phatdev.net/en/blog/owasp-top10-audit-checklist). 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](https://phatdev.net/en/blog/authorized-web-pentest-basics).

## References

- [Nginx - ngx_http_headers_module (add_header, quy tắc kế thừa)](https://nginx.org/en/docs/http/ngx_http_headers_module.html)
- [Nginx - ngx_http_limit_req_module](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html)
- [Nginx - ngx_http_realip_module](https://nginx.org/en/docs/http/ngx_http_realip_module.html)
- [Nginx - ngx_http_ssl_module](https://nginx.org/en/docs/http/ngx_http_ssl_module.html)
- [MDN - Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy)
- [MDN - Strict-Transport-Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security)
- [Mozilla SSL Configuration Generator](https://ssl-config.mozilla.org/)
- [Let's Encrypt - Ending OCSP Support in 2025](https://letsencrypt.org/2024/12/05/ending-ocsp/)
- [MDN - X-Forwarded-For (cảnh báo tin phần tử đầu)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For)

## Related posts

- [Self-Check Your Website's Security with the OWASP Top 10 (2025)](https://phatdev.net/en/blog/owasp-top10-audit-checklist.md)
- [Testing Your Own Website's Defenses With Permission: The Basics](https://phatdev.net/en/blog/authorized-web-pentest-basics.md)
- [What Is a Passkey? Passwordless Sign-In Explained, and Whether Your Website Should Add It](https://phatdev.net/en/blog/passkeys-explained.md)
