Routing and Middleware in OpenResty
How OpenResty Processes a Request
Before you can understand routing and middleware, you need to understand what happens when an HTTP request arrives at your OpenResty server. Think of OpenResty as a factory assembly line. A request walks in the door, gets handed off from one station to the next, and eventually a response rolls out the other end. Each station has a specific job. Some stations inspect the request, some modify it, and some generate the response. The order of stations is fixed, and a request must pass through each one.
In OpenResty, these stations are called phases. Nginx (the web server that OpenResty extends) defines a fixed pipeline of phases that every request passes through. Each phase has a specific purpose, and you can drop in Lua code at most of these phases to do custom work.
The phases execute in this order:
| Phase | What happens here |
|---|---|
server_rewrite | Earliest point where Lua can run. Good for server-wide rewrites. |
rewrite | URL rewriting and initial routing logic. |
access | Authentication and authorization checks. |
precontent | Runs right before the content phase. Useful for preprocessing. |
content | Generates the actual response. This is where your app lives. |
log | Logging and cleanup after the response is sent. |
There are a few more phases (like post-read and preaccess), but these are the ones you will work with most often. The key thing to remember is: phases run in a fixed order. You cannot skip ahead or go backwards. Once a request leaves the rewrite phase, it will never come back to it.
The Rewrite Phase: Your First Router
The rewrite phase is where you typically implement your routing logic. Routing means deciding “what should handle this request?” Should it go to your Lua code? Should it be redirected somewhere else? Should the URL be rewritten before anything else looks at it?
In OpenResty, you use the rewrite_by_lua_block directive to run Lua code during the rewrite phase. Here is the simplest possible example:
location / {
rewrite_by_lua_block {
-- This runs for every request to /
ngx.say("Hello from the rewrite phase!")
ngx.exit(ngx.HTTP_OK)
}
}
That example is too simple to be useful, so let’s build something that actually routes. Suppose you have two endpoints: /api/users and /api/products. You want Lua to decide which one handles the request.
location /api {
rewrite_by_lua_block {
-- ngx.var.uri holds the requested path, without the query string
local uri = ngx.var.uri
if uri == "/api/users" then
ngx.exit(ngx.OK)
-- Falling through to content_by_lua below
elseif uri == "/api/products" then
ngx.exit(ngx.OK)
-- Falling through to content_by_lua below
else
-- No matching route — return 404
ngx.exit(ngx.HTTP_NOT_FOUND)
end
}
-- This location acts as a fallback; the actual routing logic
-- above decides whether we get here or not.
content_by_lua_block {
ngx.say("You reached the content phase for: " .. ngx.var.uri)
}
}
A few things to notice here. First, ngx.var.uri is how you read Nginx variables. In this case it gives you the path part of the URL. Second, calling ngx.exit(ngx.OK) does not terminate the request — it just exits the rewrite phase and lets the request continue to the next phase. To actually stop a request and send a response, you pass an HTTP status code like ngx.HTTP_NOT_FOUND or ngx.HTTP_INTERNAL_SERVER_ERROR to ngx.exit.
The Access Phase: Middleware for Authentication
Once the rewrite phase has decided where a request should go, the access phase runs. This is the ideal place for middleware — code that runs before your main application logic and checks something about the request. Authentication is the classic example: you check an API key, validate a session token, or verify a username and password.
Here is a simple API key check implemented in the access phase:
location /api {
access_by_lua_block {
-- Read the API key from the request headers
local api_key = ngx.req.get_headers()["X-API-Key"]
-- NOTE: In production, load this from a secrets manager or env var
local expected_key = os.getenv("API_KEY") or "DEMO_KEY"
if not api_key or api_key ~= expected_key then
-- Set a 401 response and terminate the request
ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
-- If the key is correct, the request continues to the next phase
}
content_by_lua_block {
ngx.say("You have access to the API!")
}
}
The critical difference from rewrite_by_lua_block is that returning a non-OK status from access_by_lua_block terminates the request immediately. In the example above, if the API key is wrong, ngx.exit(ngx.HTTP_UNAUTHORIZED) sends a 401 response and stops the request right there. The content_by_lua_block never runs.
This is what makes the access phase so useful for middleware: you can validate credentials in one place, and if validation fails, the request never reaches your application code. Your content handler stays clean and focused on business logic.
You can also chain multiple middleware steps by stacking access_by_lua_block directives. Each one runs in sequence, and if any one of them returns an error status, the chain breaks:
http {
-- Declare a shared memory zone for rate limiting
-- Must be declared in the http block before use
lua_shared_dict ip_limit 10m;
server {
listen 8080;
location /api {
-- First: check API key
access_by_lua_block {
local key = ngx.req.get_headers()["X-API-Key"]
-- NOTE: In production, load this from a secrets manager or env var
local expected_key = os.getenv("API_KEY") or "DEMO_KEY"
if not key or key ~= expected_key then
ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
}
-- Second: check rate limit
access_by_lua_block {
-- NOTE: ip_limit must be declared with lua_shared_dict in the http block
local shared = ngx.shared.ip_limit
local key = ngx.var.remote_addr
local count, err = shared:incr(key, 1)
if not count then
-- First request from this IP, initialize counter
shared:set(key, 1, 60) -- 1 request per 60 seconds
elseif count > 10 then
ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
end
}
content_by_lua_block {
ngx.say("Request approved!")
}
}
}
}
This pattern of running multiple access-phase handlers in sequence is how you build middleware stacks in OpenResty — similar in spirit to middleware in Express.js or Django.
The Content Phase: Generating Responses
The content phase is where the actual response gets generated. This is what a user actually sees. In OpenResty, you use content_by_lua_block (or content_by_lua_file for larger scripts) to write your response logic.
One important gotcha: when you use content_by_lua_block, it completely replaces the normal Nginx content handler. That means directives like root (which tells Nginx where to find static files) and proxy_pass (which forwards requests to another server) are ignored. Your Lua code is responsible for the entire response.
location /hello {
content_by_lua_block {
ngx.header["Content-Type"] = "text/plain"
ngx.say("Hello, world!")
}
}
Here, ngx.header lets you set response headers, and ngx.say writes output followed by a newline (similar to print in many languages). ngx.print does the same thing but without the newline.
Routing by HTTP Method
Real APIs need to handle the same URL differently depending on the HTTP verb. GET /users might return a list, while POST /users creates a new entry. Use ngx.req.get_method() to inspect the request method:
location /users {
content_by_lua_block {
local method = ngx.req.get_method()
local uri = ngx.var.uri
if method == "GET" then
ngx.header["Content-Type"] = "application/json"
ngx.say('{"users": ["alice", "bob", "carol"]}')
elseif method == "POST" then
ngx.header["Content-Type"] = "application/json"
ngx.say('{"message": "User created", "uri": "' .. uri .. '"}')
else
ngx.exit(ngx.HTTP_METHOD_NOT_ALLOWED)
end
}
}
Pattern-Based Routing
For URLs with dynamic segments like /api/users/42 or /api/products/some-slug, you need pattern matching. Use string.match with Lua patterns (or ngx.re.match for regex):
location /api {
content_by_lua_block {
local uri = ngx.var.uri
-- Match /api/users/<id> using a Lua pattern
local user_id = string.match(uri, "^/api/users/([%w]+)$")
if user_id then
ngx.header["Content-Type"] = "application/json"
ngx.say('{"user_id": "' .. user_id .. '"}')
return
end
-- Match /api/products/<slug>
local slug = string.match(uri, "^/api/products/([%w-]+)$")
if slug then
ngx.header["Content-Type"] = "application/json"
ngx.say('{"product": "' .. slug .. '"}')
return
end
ngx.exit(ngx.HTTP_NOT_FOUND)
}
}
The pattern [%w]+ matches one or more word characters (letters, digits, underscores). If you need more complex matching, ngx.re.match supports full Perl-compatible regex:
-- ngx.re.match for more complex patterns
local m, err = ngx.re.match(uri, "^/blog/(\\d{4})/(\\d{2})/([a-z-]+)$")
if m then
ngx.say("Year: " .. m[1] .. ", Month: " .. m[2] .. ", Slug: " .. m[3])
else
ngx.exit(ngx.HTTP_NOT_FOUND)
end
Using Internal Subrequests
If you want to serve static files from within Lua, you can use ngx.location.capture to ask Nginx to serve them. This creates an internal subrequest — the client never sees it, but Lua can fetch the output of another location as if it were an HTTP response:
location /static {
alias /var/www/static/;
}
location / {
content_by_lua_block {
local path = ngx.var.uri
if string.match(path, "^/static/") then
-- Forward to Nginx's static file handler
local res = ngx.location.capture(ngx.var.request_uri)
ngx.header["Content-Type"] = res.header["Content-Type"]
ngx.print(res.body)
else
ngx.exit(ngx.HTTP_NOT_FOUND)
end
}
}
Note that ngx.location.capture cannot be used with locations that themselves use add_before_body, add_after_body, auth_request, or echo_location directives — those are subrequest-generating directives and cannot be captured re-entrantly.
Setting Response Headers with header_filter_by_lua_block
If you want to set headers on every response (not just specific locations), use header_filter_by_lua_block. It runs after your content handler has decided what to return but before the headers are sent to the client:
server {
listen 8080;
server_name localhost;
-- Add a custom header to every response
header_filter_by_lua_block {
ngx.header["X-Server"] = "OpenResty"
ngx.header["X-Request-Id"] = ngx.var.request_id
}
location / {
content_by_lua_block {
ngx.say("Hello, world!")
}
}
}
header_filter_by_lua_block is the right place for concerns that cut across all responses — security headers, CORS headers, or adding request IDs for tracing.
Combining Phases: A Practical Example
Let us put everything together. Here is a configuration that uses all three phases for a simple JSON API:
worker_processes 1;
error_log /tmp/openresty-error.log info;
events {
worker_connections 128;
}
http {
-- Initialize a shared memory zone for rate limiting
lua_shared_dict ratelimit 1m;
server {
listen 8080;
server_name localhost;
-- Add a custom header to every response
header_filter_by_lua_block {
ngx.header["X-Server"] = "OpenResty"
ngx.header["X-Request-Id"] = ngx.var.request_id
}
# --- REWRITE PHASE: simple URL normalization ---
rewrite_by_lua_block {
local uri = ngx.var.uri
-- Remove trailing slashes for consistency (but preserve root "/")
local new_uri = string.match(uri, "^(.+)/$")
if new_uri then
ngx.req.set_uri(new_uri, false)
end
}
# --- ACCESS PHASE: rate limiting middleware ---
access_by_lua_block {
local key = ngx.var.remote_addr -- Client IP address
local dict = ngx.shared.ratelimit
local limit = 10 -- Max 10 requests
local window = 60 -- Per 60 seconds
local current, err = dict:get(key)
if current and current >= limit then
ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
end
local new_val, err = dict:incr(key, 1)
if not new_val then
dict:set(key, 1, window)
end
}
# --- ACCESS PHASE: authentication check ---
access_by_lua_block {
-- Skip auth for health check endpoint
if ngx.var.uri == "/health" then
return -- Continue to next phase
end
local token = ngx.req.get_headers()["Authorization"]
if not token then
ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
-- In a real app, you would validate the token here
}
# --- CONTENT PHASE: route to handlers ---
content_by_lua_block {
local uri = ngx.var.uri
local method = ngx.req.get_method()
if uri == "/health" then
ngx.header["Content-Type"] = "application/json"
ngx.say('{"status": "ok"}')
elseif uri == "/users" then
if method == "GET" then
ngx.header["Content-Type"] = "application/json"
ngx.say('{"users": ["alice", "bob", "carol"]}')
elseif method == "POST" then
ngx.header["Content-Type"] = "application/json"
ngx.say('{"message": "User created"}')
else
ngx.exit(ngx.HTTP_METHOD_NOT_ALLOWED)
end
else
ngx.exit(ngx.HTTP_NOT_FOUND)
end
}
# --- LOG PHASE: record request details ---
log_by_lua_block {
-- Log the request method and URI to a file (or send to a logging service)
local log_file = io.open("/tmp/access.log", "a")
if log_file then
log_file:write(
ngx.var.request_method .. " " ..
ngx.var.uri .. " " ..
ngx.var.status .. "\n"
)
log_file:close()
end
}
}
}
Here’s what to notice in this config:
In the rewrite phase, we used ngx.req.set_uri(new_uri, false) to modify the URL internally without telling the client. The second argument false means “do an internal rewrite, not an HTTP redirect.” If you passed true, OpenResty would send a 302 redirect to the browser.
In the access phase, we used ngx.shared.ratelimit, which is a shared memory dictionary. This is one of the ways OpenResty lets you share data across all worker processes. In the rate limiting code, dict:incr atomically increments a counter, and dict:set initializes the counter with a TTL (time-to-live) of 60 seconds. This means each client’s request counter resets every minute.
In the content phase, we routed between endpoints based on both the URI and the HTTP method, setting the Content-Type header manually before writing the response with ngx.say.
Finally, in the log phase, we opened a file and wrote a simple access log entry. In production, you would probably send logs to a service like syslog or a hosted logging platform instead.
Common Mistakes and How to Avoid Them
Confusing rewrite and access phases. The rewrite phase is for routing and URL manipulation. The access phase is for security checks. Do not try to do authentication in the rewrite phase — ngx.exit in the rewrite phase does not terminate the request the way it does in the access phase.
Thinking ngx.exit behaves the same everywhere. In the rewrite_by_lua_block, ngx.exit(status) exits the rewrite handler but the request continues to the next phase (unless you pass a status >= 200 that is not ngx.OK). In access_by_lua_block, any non-OK status terminates the request immediately. In content_by_lua_block, ngx.exit terminates the request and the content phase. The log phase still runs normally — use it for any final cleanup or logging you need.
Using ngx.socket.tcp() in the wrong phase. Cosockets (OpenResty’s non-blocking networking API) are only available in the content, access, rewrite, and precontent phases. If you try to use them in log_by_lua_block or header_filter_by_lua_block, your code will fail. The error message you see will be something like “attempt to yield across C-call boundary.”
Forgetting that data is per-worker by default. If you store something in a local variable in your Lua code, it only exists for the current worker process. If you need to share data across all workers (like a session store), use ngx.shared.DICT or an external store like Redis.
Not declaring lua_shared_dict before use. Every ngx.shared.DICT must be declared in the http block with lua_shared_dict <name> <size>; before any worker process can use it. Forgetting this causes a runtime panic.
Summary
Routing and middleware in OpenResty come down to understanding the request pipeline. The rewrite phase lets you inspect and modify the URL before your application sees it. The access phase is a gatekeeper where authentication, authorization, and rate limiting live — and it is the only phase where returning an error truly stops a request in its tracks. The content phase is your application layer, where you generate the actual response, handle different HTTP methods, and match URL patterns. The log phase handles cleanup and recording.
Each phase is a slot where you drop in Lua code. By stacking multiple handlers in the same phase, you build a middleware chain. By using different phases for different concerns, you keep your code organized and each piece focused on a single responsibility.
See Also
- Getting Started with OpenResty — the first article in this series, covering installation and your first OpenResty configuration