OpenResty: Lua inside Nginx
Article.
Verified 2026-10Verified 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
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
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:
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 sThe 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
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.executestalls every connection on the worker; latency spikes everywhere at once. Use cosocket-basedlua-resty-*libraries only. lua_code_cache offleft 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_dicthas a fixed size; when full, it evicts least-recently-used entries, so a rate limiter or cache quietly forgets keys. Size it and watchfree_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-Idbut 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.
Related
Interview angle 4
- “What is OpenResty?” - Nginx with LuaJIT, so you can run Lua in the
request phases: authentication in
access_by_lua, responses incontent_by_lua, upstream choice inbalancer_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).
- 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 (
- “How would you rate-limit per API key at the proxy?” -
resty.limit.reqon alua_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.ctxis per request. - “OpenResty or
auth_request?” - Lua verifies tokens locally with no extra hop;auth_requestis simpler but puts an auth service call on every request.