← Back to Articles DevOps & CI/CD

Leaky Buckets at the Edge: Rate Limiting Login and Contact Endpoints in Nginx

Oselio Candido · Jul 2026 · 7 min read

The problem

An application server sitting directly on the internet has to spend its own CPU cycles deciding whether every request is legitimate. A login endpoint with no rate limiting will happily accept a credential-stuffing script's ten thousand attempts a minute, burning database connections and process time on traffic that should never have reached the app in the first place.

A public contact form with no throttling has the same problem in a different shape: it becomes a free spam relay, and every submission still walks the full request pipeline — validation, mail dispatch, logging — before anyone decides it was junk.

Rejecting a flood before it's authenticated doesn't need any application context — no account, no session, nothing the app knows that nginx doesn't. That's what makes it an edge problem worth solving at the edge, rather than paying its cost in the app's own resources: the first layer of defense here is coarse and identity-blind on purpose.

What is a leaky bucket rate limiter

Nginx's limit_req module implements the leaky bucket algorithm — but the literal picture (a bucket with a hole, requests leaking out and getting processed at a fixed rate) only describes the default, no-nodelay mode, where excess requests are queued and drained at that steady rate. That's not what's configured below. With nodelay set, nginx admits every request within the configured rate and burst immediately — nothing is metered out at a fixed pace — and only rejects what's left over once the burst allowance is also exhausted. The bucket still exists as the accounting model (it's what tracks how much of the allowance is currently used), it just isn't imposing a delay on the requests it lets through.

Two concepts do all the work:

  • The zone — shared memory where nginx tracks per-key state. Keying on $binary_remote_addr means one bucket per client IP; a 10 MB zone holds state for roughly 160,000 addresses. That state is local to the nginx process holding it — two nginx instances each running this config keep two independent buckets, not one shared 5 req/min limit across both. For a single-box deployment that's a non-issue; behind a load balancer fronting multiple app servers, a per-IP limit enforced this way is really "N times the configured rate," and getting back to one true global limit needs a shared store (Redis, most commonly) instead of nginx's in-process zone.
  • Burst + nodelay — real traffic is spiky. burst is the bucket's depth: how many requests over the steady rate are tolerated. nodelay says "serve burst requests immediately instead of queuing them" — excess beyond the burst is rejected with an error, not delayed.

For comparison, a token bucket — the other classic algorithm, used by many API gateways — accumulates permission over idle time, allowing genuine bursts at full speed. Nginx's leaky bucket with nodelay behaves almost identically in practice, which matters for what comes next: it meant reaching for the module nginx already ships with, instead of a heavier piece of middleware.

The alternatives

Rate limiting could have been implemented inside the application instead of at the edge, and each option carried real trade-offs.

  • Application-level rate limiting (a middleware decorator counting requests per IP or per user in the app process, backed by an in-memory dict or Redis) — gives finer control, since the app knows who's logged in, not just which IP is asking. But every rejected request still costs a full trip through the app's request pipeline before it can be turned away, and the floods that matter most are exactly the ones you want rejected before they ever reach application code.
  • A dedicated API gateway or WAF (Kong, Cloudflare, AWS API Gateway) — offers richer rules, geo-based limits, bot scoring. For a single-box deployment this is real infrastructure and cost for a problem two lines of nginx config solve just as well at the traffic volumes involved.
  • No rate limiting, rely on infrastructure-level DDoS protection — covers volumetric floods but does nothing for a low-and-slow credential-stuffing attempt that looks like ordinary traffic to a network-layer filter.
ApproachStrengthCost
Nginx limit_reqCheap, rejects before the appMostly IP-scoped, no account context
Application-levelKnows user/accountApp still pays for every rejected request
Redis-backedShared state across instancesExtra infrastructure to run and operate
API gateway / WAFRich rules, distributed edgeInfrastructure and cost overkill at this scale

Given a single-box deployment already running nginx as a reverse proxy in front of the app, the edge was already there — the question was whether to use it or bypass it. That framing matters: the choice below is right for this deployment, not a claim that nginx is the general answer to rate limiting.

The decision

Not every endpoint needs limiting — the dangerous ones are authentication (credential stuffing) and the public contact form (spam). This is the actual config, from the host nginx file in front of the FastAPI backend on 127.0.0.1:8001. Each endpoint got its own zone, sized to how abusive traffic against it actually looks:

limit_req_zone $binary_remote_addr
    zone=login_limit_beta:10m   rate=5r/m;

limit_req_zone $binary_remote_addr
    zone=contact_limit_beta:10m rate=3r/m;

And each zone is applied at its location, with a burst allowance and an honest error contract:

location /api/auth/ {
    limit_req zone=login_limit_beta burst=3 nodelay;
    limit_req_status 429;
    add_header Retry-After 60 always;

    proxy_pass http://fastapi_backend_beta/api/auth/;
    proxy_connect_timeout 5s;
    proxy_send_timeout 5s;
    proxy_read_timeout 5s;
}

location = /api/contact {
    limit_req zone=contact_limit_beta burst=5 nodelay;
    limit_req_status 429;
    add_header Retry-After 60 always;

    proxy_pass http://fastapi_backend_beta/api/contact;
    proxy_connect_timeout 10s;
    proxy_send_timeout 10s;
    proxy_read_timeout 10s;
}

Reading the login numbers: a client may make 5 auth requests per minute sustained, with 3 extra tolerated in a spike — plenty for a human who mistypes a password twice and then finds their TOTP code, hopeless for a script trying a password list. Rejected requests get 429 Too Many Requests plus a Retry-After: 60 header — not a guarantee that the client will be accepted the instant 60 seconds elapse, since that depends on the bucket's state at the time, but an explicit signal that a well-behaved client should back off instead of retrying immediately. Rejecting them here also means those requests never traverse the application stack at all — no database connection, no password hashing, no app-process time spent on the flood, just a shared-memory lookup in nginx.

The two locations also carry different proxy timeouts — 5 seconds on /api/auth/, 10 on /api/contact — which isn't part of the rate-limiting logic itself, but reflects the same instinct: an auth check should be fast and fail fast, while a contact-form submission (which may trigger mail dispatch downstream) is given more room before nginx gives up on the upstream.

Per-IP, not per-account

At the edge, the IP is all nginx knows — it can't see who the login is for. Per-IP limiting is the coarse outer wall; finer per-account protections, like MFA (covered in its own write-up), belong in the application, where identity actually exists.

The edge handles what it can see cheaply; the app handles what requires context — that division of labor, not any single directive, is the actual design decision here.

Per-IP limiting has a known blind spot worth naming rather than glossing over: corporate NATs put many humans behind one IP, so they share a bucket, and a botnet spreads one attacker across many IPs, so no single bucket ever fills. The mitigation doesn't eliminate credential stuffing — it raises its cost enough that unsophisticated scripts fail outright and sophisticated ones have to work much harder, while human users are barely affected.

The outcome

  • Authentication and contact-form abuse now gets rejected before it ever touches application code: a flood past the configured rate returns a 429 with a clear Retry-After hint, at the cost of a shared-memory lookup instead of a full request-handling cycle.
  • The same zone definitions carry across environments — production, staging, and a demo tier — differing only in the upstream port and zone name, so the same protection applies everywhere the app is reachable, at effectively zero ongoing cost beyond the initial configuration.