Backend / Protocols / nginx / 15_openresty.md

OpenResty: Lua inside Nginx

Updated 4 interview angles 6 min read source
On this page8
  1. The mechanism: Lua runs in Nginx’s phases, on the event loop
  2. Authentication at the edge
  3. Rate limiting with lua-resty-limit-traffic
  4. Dynamic routing
  5. Where it breaks in production
  6. The trade not taken: a separate auth service on every request
  7. Related
  8. Interview angle

OpenResty: Lua inside Nginx

Article.

Verified 2026-10

Verified 2026-10-15: OpenResty 1.31.1.1 (2026-05-13), lua-nginx-module and lua-resty-limit-traffic documentation. There is no current Windows build, so the rate-limit run below reproduces the library’s arithmetic in Python.

OpenResty is Nginx bundled with LuaJIT and a set of Lua libraries, so request handling logic (authentication, routing decisions, rate limits) can run inside the proxy instead of in a separate service. API gateways such as Kong and Apache APISIX are built on it. The interview question is what you can safely do inside Nginx’s event loop, and when that is better than another hop to a service. The answer turns on one rule: never block a worker.

The mechanism: Lua runs in Nginx’s phases, on the event loop

Nginx processes a request in phases, and OpenResty lets you attach Lua to each:

Directive Runs Typical use
init_by_lua_block once, master process load config, preload modules
init_worker_by_lua_block once per worker timers, background refresh
ssl_certificate_by_lua_block TLS handshake pick a certificate per SNI
rewrite_by_lua_block per request rewrite the URI, set variables
access_by_lua_block per request authentication, rate limiting
content_by_lua_block per request generate the response
balancer_by_lua_block per request choose the upstream peer
header_filter_by_lua_block response headers add or strip headers
body_filter_by_lua_block response body chunks rewrite the body
log_by_lua_block after the response metrics, async logging

Each Nginx worker is one OS thread running an event loop for thousands of connections, and Lua code runs on that thread. Two consequences follow:

  • Cosockets make I/O non-blocking. ngx.socket.tcp, and libraries built on it (lua-resty-redis, lua-resty-http), yield the coroutine while waiting, so the worker serves other requests meanwhile. They are disabled in the phases where yielding is impossible: set_by_lua*, header_filter_by_lua*, body_filter_by_lua*, log_by_lua* and the init phases.
  • Anything else that waits blocks the worker. LuaSocket, io.* file reads, os.execute, a long CPU loop: while one runs, every connection on that worker stalls. The same rule as a blocking call in asyncio (run_in_executor and asyncio.to_thread).

State shared between workers lives in a lua_shared_dict: a named shared-memory zone with atomic operations, the only memory all workers of one Nginx instance see. Per-request state goes in ngx.ctx.

Authentication at the edge

nginx
http {
    lua_shared_dict token_cache 10m;

    server {
        location /api/ {
            access_by_lua_block {
                local auth = require "auth"            -- your module, cached code
                local claims, err = auth.verify(ngx.var.http_authorization)
                if not claims then
                    ngx.log(ngx.INFO, "rejected: ", err)
                    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
                end
                ngx.req.set_header("X-User-Id", claims.sub)   -- trusted downstream
                ngx.req.clear_header("Authorization")
            }
            proxy_pass http://app;
        }
    }
}

Verifying a JWT signature is local CPU work and safe in the access phase; introspecting an opaque token means an HTTP call, which must go through lua-resty-http (a cosocket) and should be cached in the shared dict, or the gateway adds the identity provider’s latency to every request. The upstream must trust X-User-Id only from the proxy, and the proxy must overwrite it rather than pass a client-supplied value through.

Rate limiting with lua-resty-limit-traffic

nginx
lua_shared_dict limit_req_store 100m;

location /api/ {
    access_by_lua_block {
        local limit_req = require "resty.limit.req"
        local lim = limit_req.new("limit_req_store", 10, 5)   -- 10 r/s, burst 5
        local key = ngx.var.http_x_api_key or ngx.var.binary_remote_addr
        local delay, err = lim:incoming(key, true)
        if not delay then
            if err == "rejected" then
                return ngx.exit(429)
            end
            return ngx.exit(500)
        end
        if delay >= 0.001 then
            ngx.sleep(delay)                                  -- yields, does not block
        end
    }
    proxy_pass http://app;
}

What the burst actually means

The library uses the same leaky-bucket arithmetic as Nginx’s limit_req, counted in thousandths of a request. Reproduced in Python for 20 requests arriving in the same millisecond:

text
20 simultaneous requests at rate=10/s, burst=5:
  served: 6, delays (s): [0.0, 0.1, 0.2, 0.3, 0.4, 0.5]
  rejected: 14
  one second later, next request delay: 0.00 s

The burst is not “five extra requests for free”: the first request passes, the next five wait 0.1-0.5 s to fit the rate, and the rest are rejected immediately.

What Lua adds over plain limit_req is the key and the decision: limit by API key or tenant from a header or a JWT claim, per plan, with different limits looked up at request time. Note the state is per Nginx instance: three instances behind a load balancer each allow 10 r/s, so divide the limit or move the counter to Redis.

Dynamic routing

nginx
location / {
    set $upstream "";
    access_by_lua_block {
        local tenant = ngx.var.http_x_tenant or "default"
        ngx.var.upstream = routes.lookup(tenant)   -- shared dict, timer-refreshed
    }
    proxy_pass http://$upstream;
}

The routing table is loaded into a shared dict by an init_worker_by_lua timer, not fetched per request; a lookup in shared memory is microseconds, a call to a config service per request is a dependency in your hot path.

Where it breaks in production

  • A blocking call in a handler. A Lua library using LuaSocket, a file read, or os.execute stalls every connection on the worker; latency spikes everywhere at once. Use cosocket-based lua-resty-* libraries only.
  • lua_code_cache off left on. It reloads Lua files on every request, for development; in production it costs CPU and breaks module-level state. The docs strongly discourage it outside development.
  • The shared dict fills up. lua_shared_dict has a fixed size; when full, it evicts least-recently-used entries, so a rate limiter or cache quietly forgets keys. Size it and watch free_space().
  • Per-instance limits mistaken for global ones. See above: N instances allow N times the limit.
  • Trusting client headers. A gateway that sets X-User-Id but does not clear an incoming one lets clients impersonate users upstream.
  • Logic that belongs in the app. Business rules written in Lua inside the proxy are hard to test, deploy and debug; the gateway should hold cross-cutting concerns only.

The trade not taken: a separate auth service on every request

The alternative to Lua at the edge is Nginx’s auth_request (a subrequest to an auth service per request) or an auth check in every application. A subrequest is simple and keeps logic in your usual language, at the cost of a network hop and the auth service’s availability on every request. Lua in the access phase verifies tokens locally and caches decisions in shared memory, which is why gateways are built this way.

Use OpenResty when the per-request logic is small, hot and cross-cutting (auth, limits, routing); keep anything domain-specific in the services behind it.

Interview angle 4

  • “What is OpenResty?” - Nginx with LuaJIT, so you can run Lua in the request phases: authentication in access_by_lua, responses in content_by_lua, upstream choice in balancer_by_lua. Kong and APISIX are built on it.
    • Follow-up: what must never happen in that Lua? Blocking I/O: it runs on the worker’s event loop, so a LuaSocket call or a file read stalls every connection on the worker. Use cosockets (lua-resty-http, lua-resty-redis).
  • “How would you rate-limit per API key at the proxy?” - resty.limit.req on a lua_shared_dict, keyed by the API key header; sleep for the returned delay, return 429 when rejected.
    • Follow-up: 20 requests arrive at once with rate 10 and burst 5? Six are served, delayed 0 to 0.5 s, fourteen rejected; and the state is per instance, so three instances allow three times the rate.
  • “How do workers share state?” - lua_shared_dict, a fixed-size shared memory zone with LRU eviction when full; ngx.ctx is per request.
  • “OpenResty or auth_request?” - Lua verifies tokens locally with no extra hop; auth_request is simpler but puts an auth service call on every request.