Skip to content
DevOps devops webservers 6 min read

Rate Limiting & Security Headers

Once your site is live on the internet, it will get attacked. Bots will hammer your login page trying to guess passwords (a “brute-force attack”), and browsers will happily run malicious scripts if you let them. Nginx (a popular web server, pronounced “engine-x”) gives you two cheap, powerful defenses you can turn on in minutes: rate limiting (slowing down clients that send too many requests) and security headers (small instructions you send to the browser telling it how to behave safely). This page shows you exactly how to configure both on Ubuntu 22.04/24.04 LTS.

What is rate limiting and when to use it

Rate limiting means: “no single client (usually identified by their IP address) may send more than X requests per second.” If they go over, Nginx rejects the extra requests with an error instead of passing them to your application.

When to use it: any endpoint that is expensive or sensitive. The classic examples are a login form (/login), a password-reset page, a sign-up form, an API, or a search box. These are the doors attackers push on.

When NOT to use it (or use it loosely): static assets like images, CSS, and JavaScript. A normal page load pulls in dozens of these at once, so a tight limit would block real visitors. Keep aggressive limits scoped to the dangerous endpoints, not the whole site.

How limit_req_zone and limit_req work

Rate limiting in Nginx is two pieces working together:

DirectiveWhere it goesWhat it does
limit_req_zoneinside the http {} block (global)Defines a shared memory “zone” that tracks request counts per key (e.g. per IP), and sets the allowed rate.
limit_reqinside a server {} or location {} blockActually applies the zone to specific URLs, with an optional burst.

You define the rule once globally, then apply it wherever you want.

Step 1 — define the zone

Open the main Nginx config file:

sudo nano /etc/nginx/nginx.conf

Inside the http { ... } block, add a zone:

http {
    # Track each client IP. Allow 5 requests/second. Reserve 10 MB of memory.
    limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/s;

    # ... the rest of the existing http block stays as-is ...
}

What each part means:

  • $binary_remote_addr — the client’s IP address, stored in a compact binary form. This is the “key” we count by.
  • zone=login_limit:10m — names the zone login_limit and gives it 10 megabytes of shared memory. That’s enough to track roughly 160,000 IP addresses.
  • rate=5r/s — the steady allowed rate: 5 requests per second per IP. You can also use r/m for per-minute (e.g. rate=30r/m).

Step 2 — apply the zone to your login endpoint

Now edit your site’s config in /etc/nginx/sites-available/. Apply the limit only to the login location:

server {
    listen 80;
    server_name example.com;

    location /login {
        # Apply the zone. Allow short bursts of up to 10 queued requests,
        # and serve them without delay until the burst is used up.
        limit_req zone=login_limit burst=10 nodelay;

        proxy_pass http://127.0.0.1:3000;
    }

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}
  • burst=10 — allows a temporary spike: up to 10 extra requests can queue instead of being rejected immediately. Real users sometimes double-click; this stops that from looking like an attack.
  • nodelay — serve those burst requests right away rather than spacing them out. Without it, queued requests are trickled out at the rate, which feels slow.

When a client exceeds the limit, Nginx returns HTTP status 503 by default. You can change it to the more standard 429 (Too Many Requests):

http {
    limit_req_status 429;
}

Step 3 — test and reload

Always check your config before reloading. A bad config can take your whole site down.

sudo nginx -t
sudo systemctl reload nginx

Output:

nginx: the configuration file /etc/nginx/nginx.conf syntax test is successful
nginx: configuration file /etc/nginx/nginx.conf test is ok

Now hammer the endpoint to confirm it kicks in. Send 20 fast requests:

for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" http://example.com/login; done

Output:

200
200
200
200
200
200
200
200
200
200
200
429
429
429
429
429
429
429
429
429

The first requests pass (within rate plus burst), then Nginx starts returning 429. Rate limiting works.

Gotcha: if Nginx sits behind another proxy, a load balancer, or Cloudflare, then $binary_remote_addr may be the proxy’s IP, not the real visitor’s. Every request would share one bucket and you’d block everyone at once. In that case configure the real_ip module so Nginx reads the true client IP from the X-Forwarded-For header first.

Security headers and what each one protects

Security headers are extra lines Nginx adds to every HTTP response. The browser reads them and changes its behavior to be safer. Add them inside a server {} block (or in http {} to apply site-wide):

server {
    # Stop your pages being framed by other sites (clickjacking defense).
    add_header X-Frame-Options "SAMEORIGIN" always;

    # Stop the browser guessing file types (MIME-sniffing defense).
    add_header X-Content-Type-Options "nosniff" always;

    # Force HTTPS for the next 2 years, including subdomains.
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

    # Control how much referrer info leaks to other sites.
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

Here is what each one actually defends against:

HeaderProtects againstPlain-English meaning
X-Frame-Options: SAMEORIGINClickjackingAnother website can’t load your page inside an invisible <iframe> to trick users into clicking things. Only your own domain may frame it.
X-Content-Type-Options: nosniffMIME-sniffing attacksThe browser must trust the file type you declare instead of guessing. Stops a file uploaded as an image from being run as JavaScript.
Strict-Transport-Security (HSTS)Downgrade & cookie-theft attacksAfter the first visit, the browser refuses to talk to your site over plain HTTP and always uses encrypted HTTPS.
Referrer-PolicyPrivacy / info leakageLimits how much of your URL is sent to other sites a user clicks through to.

Warning: only add the HSTS (Strict-Transport-Security) header once you have a working HTTPS certificate and have tested it. HSTS tells browsers to only use HTTPS for the duration of max-age. If HTTPS later breaks, visitors will be locked out and you cannot easily undo it from the server side.

The always keyword at the end matters: without it, Nginx skips the header on error responses (like 404 or 500 pages), leaving those pages unprotected. Always include always.

Verify your headers

After reloading, check that the headers are really being sent:

curl -I https://example.com

Output:

HTTP/2 200
server: nginx
content-type: text/html
x-frame-options: SAMEORIGIN
x-content-type-options: nosniff
strict-transport-security: max-age=63072000; includeSubDomains
referrer-policy: strict-origin-when-cross-origin

Best practices

  • Scope rate limits tightly: apply aggressive limits to /login, /signup, and API routes, and leave static assets generous or unlimited.
  • Use burst with nodelay so legitimate users with double-clicks or quick retries aren’t punished, while sustained floods are still blocked.
  • Return 429 instead of the default 503 so clients and monitoring tools correctly understand they were rate-limited.
  • Always add the always keyword to security headers so error pages are protected too.
  • Roll out HSTS carefully: start with a short max-age (e.g. max-age=300), confirm HTTPS is solid, then raise it to 1-2 years.
  • Run sudo nginx -t before every sudo systemctl reload nginx so a typo never takes your site offline.
  • Combine these with a firewall: keep ufw enabled and only allow ports 80, 443, and SSH.
Last updated June 15, 2026
Was this helpful?