Blog
OpenResty Rate Limiting: A Thin WAF Layer for Your Site
A full commercial WAF is overkill for a personal site, but rate limiting plus a handful of dead-path rules is very practical. In May I spent a week tuning parameters on 1Panel's OpenResty. The goal wasn't "block every scan" — it was: scripts get slow, real humans barely notice, and the logs stay explainable. What follows is a tuning log written in timeline order, so future-me can look back and remember why burst ended up at this number. There's no one-size-fits-all answer for these parameters — only ranges you've verified against your own traffic shape.
The starting config: too strict, version one
The first version copied conservative numbers and used a single zone:
limit_req_zone $binary_remote_addr zone=perip:10m rate=5r/s;
server {
location / {
limit_req zone=perip burst=10 nodelay;
}
location = /api/login {
limit_req zone=perip burst=3 nodelay;
}
}
429s exploded on day one. Reviewing the access log: multiple readers behind one corporate NAT sharing a single IP, plus an RSS feed fetcher hammering the same IP in short bursts. Five requests per second is plenty for one person browsing; it isn't for NAT and crawlers. Worse, static assets and HTML shared one bucket, so opening a post with lots of images caused collateral damage. After that I flipped the order — loosen the whole site first, then tighten the login endpoint individually — instead of clamping everything down globally from the start.
Tuning: let the status codes talk
After each rule change I watched for a full 24 hours, looking only at the status-code distribution and the handful of real complaints — not my gut feeling. One variable per change, or you'll never know whether it was burst that saved you or splitting the zone:
| Version | rate / burst | 429 share (approx) | Feel |
|---|---|---|---|
| Version 1 | 5r/s, burst 10 | High | Corporate-network readers complained |
| Version 2 | 10r/s, burst 20 | Acceptable | Static pages fine |
| Version 3 | Whole site 10r/s; login 2r/s, burst 5 | Concentrated on login | As expected |
| Version 4 | Static assets got their own looser zone | Lower again | Image-heavy pages no longer hurt |
The key insight: one zone for the whole site isn't enough. Split login, API, static, and admin into separate zones with their own rate and burst. Login can be strict; static should be lenient.
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:5m rate=2r/s;
limit_req_zone $binary_remote_addr zone=static:10m rate=30r/s;
location /assets/ {
limit_req zone=static burst=50 nodelay;
}
location = /api/login {
limit_req zone=login burst=5 nodelay;
}
location / {
limit_req zone=perip burst=20 nodelay;
}
Both IPv6 and IPv4 have to land in your key design; shipping with only v4 tested means a v6 scanner that "looks unthrottled" will embarrass you. Whether to use nodelay also depends on the business: for login I'd rather squeeze bursts harder, while static pages can be more generous. Zone memory size (like 10m) just needs to match your concurrent-IP scale — on a personal site you rarely hit that ceiling first.
Dead paths: cheaper than rate limiting
The most-scanned things on a personal site are other people's CMS paths. Cut those off outright instead of letting them burn your limit budget:
location ~* ^/(\.env|\.git|wp-admin|wp-login\.php|phpmyadmin) {
return 444;
}
444 makes Nginx and OpenResty close the connection outright — no small talk. Combined with "admin paths only reachable via VPN or allowlisted IPs," this is where most of the thin WAF's value lives. For unusual user agents, log first and decide whether to block later — I once blocked a real browser with a blind blacklist, and after that I only block clear exploit-kit fingerprints, not anything with a weird name.
The scanner-path list doesn't need to be exhaustive: grow it from the top paths in your own access log and check once a month for new faces — that's healthier than copying a giant ruleset you never dare touch. Rule changes need the same observation window: did any legit deep links get 444'd?
Coexisting with real users
Many people behind an office NAT share one IP, so an address-only key will hurt them — leave headroom in burst. Finer keys for logged-in sessions need Lua or app-layer cooperation, which usually isn't worth it on a personal site. You can allowlist known UAs for link-checker crawlers and feeds, or give them a separate, higher quota. When Cloudflare sits in front, the origin sees the edge's return address, so your rate-limit key and your real attack surface have to line up: the edge already blocks a batch, the origin catches the rest, and the two rulesets shouldn't fight each other.
After every change, watch the limit_req-related logs and the 429/444 distribution for at least a day. Blindly copying someone else's "10 req/s" is meaningless — your reader mix, whether you're behind a CDN, and whether you have an API are all different. I got into the habit of logging "change → observation window → one-line conclusion" in the changelog, and three months later that beats memory every time.
Where it sits in the stack
This layer exists to kill script noise and low-grade exploit paths. It doesn't replace app authentication, dependency updates, a minimal attack surface, or putting admin entry points inside a tunnel. On a personal site your adversary is mostly automation: you don't need an infinite arms race, just parameters tuned against your own logs and the nerve to look at the next day's status codes. Thin and explainable beats thick and mysterious for a site maintained by one person.
The only reusable part of this tuning log is the method: let status codes talk, split the zones, loosen first then tighten, and observe after every change. The specific numbers aren't worth copying — write down your own 24-hour distribution, and the next change has a frame of reference.
If there's a CDN in front, first figure out what the edge already blocks, then decide how tight the origin zones should be, so the two sides don't double-count or trip each other up. For a personal site, rate limiting as "a thin, explainable layer" is enough — you don't need to write it into a second firewall ruleset.