Lua & OpenResty #
Nginx is famous as a very fast web server due to its event-driven architecture. However, traditionally, adding business logic or complex dynamic programming into Nginx required writing custom modules in the C language. Writing C modules is very prone to memory leaks and can crash the entire server with even the smallest mistake.
The Lua integration through the ngx_http_lua_module completely changes this paradigm. By combining Nginx’s efficiency, the very fast LuaJIT (Just-In-Time compiler) interpreter, and non-blocking libraries, we can write high-performance dynamic application logic directly at the web server level. OpenResty comes as an Nginx-based web platform that packages all those libraries into one unified ecosystem ready for large-scale production needs.
OpenResty vs Manual Nginx + ngx_lua #
There are two main paths we can choose to run Lua code inside our Nginx environment:
- OpenResty (Highly Recommended):
This is a ready-to-use Nginx distribution professionally maintained by the OpenResty community. Inside it, a stable Nginx version, the high-performance LuaJIT interpreter, the core
ngx_luamodule, and dozens of built-in non-blocking libraries for accessing Redis, MySQL, Postgres, JSON parsers, and DNS resolvers are already installed. This path is the most stable and easiest to manage on production servers. - Manual
ngx_luaModule Compilation: This path is chosen if we have an existing custom Nginx installation running and don’t want to replace its main binary with OpenResty. We must install LuaJIT dependencies ourselves on the OS, download thengx_devel_kitandlua-nginx-modulesource modules, then compile them together with Nginx. This path requires more complicated manual maintenance during version updates.
OpenResty Installation Guide on Production Servers #
Here are practical steps to install OpenResty on standard Linux distributions:
1. Installation on Ubuntu or Debian #
We use the official OpenResty repository to always get the latest security version updates:
# Install initial dependencies
sudo apt-get update
sudo apt-get -y install --no-install-recommends wget gnupg ca-certificates lsb-release
# Import the official OpenResty GPG Key
wget -O - https://openresty.org/package/pubkey.gpg | sudo gpg --dearmor -o /usr/share/keyrings/openresty.gpg
# Add the APT repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/ubuntu $(lsb_release -sc) main" \
| sudo tee /etc/apt/sources.list.d/openresty.list
# Update the index and install the OpenResty package
sudo apt-get update
sudo apt-get install openresty -y
# Start the OpenResty service
sudo systemctl start openresty
sudo systemctl enable openresty
After installation completes, the OpenResty working directory is at /usr/local/openresty/. Its main configuration file is stored at /usr/local/openresty/nginx/conf/nginx.conf, and the running Nginx binary file is at /usr/local/openresty/nginx/sbin/nginx.
Nginx Request Handling Phases #
To write effective Lua code, we must understand where our code executes in the Nginx request processing lifecycle. Nginx divides HTTP processing into 11 sequential phases. The ngx_lua module provides special directives (hooks) to insert our Lua code into those important phases.
Here’s a visualization of the Nginx request processing phase flow along with the Lua directive entry points:
flowchart TD
Request["Client Request Arrives"] --> PostRead["1. post-read"]
PostRead --> Rewrite["2. rewrite"]
NoteRewrite["rewrite_by_lua_block<br/>(URI Manipulation / Redirect)"] -.-> Rewrite
Rewrite --> FindConfig["3. find-config"]
FindConfig --> Preaccess["4. preaccess"]
Preaccess --> Access["5. access"]
NoteAccess["access_by_lua_block<br/>(Authentication & Authorization)"] -.-> Access
Access --> PostAccess["6. post-access"]
PostAccess --> Precontent["7. precontent"]
Precontent --> Content["8. content"]
NoteContent["content_by_lua_block<br/>(Generate response content)"] -.-> Content
Content --> Filter["9. filter (Header & Body)"]
NoteFilter["header_filter_by_lua_block<br/>body_filter_by_lua_block"] -.-> Filter
Filter --> Log["10. log"]
NoteLog["log_by_lua_block<br/>(Log / send metrics)"] -.-> Log
Log --> Response["Response Sent to the Client"]
classDef default fill:#f9f9f9,stroke:#d1d5db,stroke-width:1px,color:#111827;
classDef phaseStyle fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a;
classDef luaStyle fill:#fff7ed,stroke:#ea580c,stroke-width:1px,stroke-dasharray: 5 5,color:#c2410c;
class PostRead,Rewrite,FindConfig,Preaccess,Access,PostAccess,Precontent,Content,Filter,Log phaseStyle;
class NoteRewrite,NoteAccess,NoteContent,NoteFilter,NoteLog luaStyle;Explanation of the Lua Phase Directive Roles: #
init_by_lua_block: Runs when Nginx does initial startup or a configuration reload at the master process level. Very suitable for initializing global variables or pre-loading Lua libraries.init_worker_by_lua_block: Runs right after the worker process is created. Useful for creating background timers (cron-like jobs) usingngx.timer.atfor periodic data synchronization.rewrite_by_lua_block: Executes Lua code before the location lookup happens. Often used for dynamic URL rewriting.access_by_lua_block: The best phase for handling security rules, application-level firewalls (WAF), API token verification, and rate limiting. If authorization fails, we can cut the request before it touches the backend.content_by_lua_block: The place to generate HTTP responses directly. This is where our dynamic application code runs (similar to a handler in Node.js or Go).header_filter_by_lua_block: Lets us dynamically manipulate response headers after the content is done being generated.body_filter_by_lua_block: Functions as a response body stream filter. We can censor text, change formats, or insert additional scripts in streaming fashion.log_by_lua_block: Runs asynchronously after the client connection is closed. Perfect for logging special data to databases, sending performance metrics to Prometheus, or collecting statistics without disturbing the client response time.
Production Case Example 1: Dynamic Hello World with JSON #
Let’s make the most basic example to verify that OpenResty can serve dynamic JSON content without touching disk:
server {
listen 8080;
server_name localhost;
location /api/status {
content_by_lua_block {
# 1. Set response headers
ngx.header.content_type = "application/json; charset=utf-8"
ngx.header.x_custom_server = "OpenResty-Lua"
# 2. Get query string parameters from the URL
local args = ngx.req.get_uri_args()
local user = args["user"] or "guest"
# 3. Send JSON data to the client
ngx.say(string.format('{"status": "running", "authorized_as": "%s", "timestamp": %d}', user, ngx.time()))
}
}
}
Production Case Example 2: Dynamic Bearer Token Authentication #
We can use access_by_lua_block to cut illegal access at the Nginx gateway level. Here’s an implementation of simple authorization token validation:
server {
listen 443 ssl;
server_name secure-gateway.unisbadri.com;
location /api/v1/secure-data {
access_by_lua_block {
# 1. Get the Authorization header from the client
local auth_header = ngx.req.get_headers()["Authorization"]
if not auth_header then
ngx.status = ngx.HTTP_UNAUTHORIZED
ngx.header.content_type = "application/json"
ngx.say('{"error": "Unauthorized", "message": "Missing Authorization Header"}')
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
# 2. Extract the Bearer token format
local token = auth_header:match("^Bearer%s+(.+)$")
if not token then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.header.content_type = "application/json"
ngx.say('{"error": "Bad Request", "message": "Invalid Authorization format"}')
return ngx.exit(ngx.HTTP_BAD_REQUEST)
end
# 3. Verify the token (Static example, can be replaced with a DB/Redis lookup)
local secret_key = "our-production-secret-token"
if token ~= secret_key then
ngx.status = ngx.HTTP_FORBIDDEN
ngx.header.content_type = "application/json"
ngx.say('{"error": "Forbidden", "message": "Access Denied: Invalid Token"}')
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
# Token is valid, let Nginx continue the request to proxy_pass
}
# Forward to the backend if it passes the Lua checks
proxy_pass http://api_backend;
}
}
Production Case Example 3: Dynamic Rate Limiting per User ID via Redis #
By default, the Nginx rate limit module only supports client-IP-based limiting. With Lua, we can create a dynamic rate limiter that restricts access based on the logged-in user ID, even though they use frequently changing IPs.
Setting Up Shared Dictionary Memory in nginx.conf #
We need to define a shared memory zone (shared dictionary) inside the http block to store rate limit counter data instantly:
http {
# Allocate 10MB of RAM for a dictionary named 'rate_limit_store'
lua_shared_dict rate_limit_store 10m;
server {
listen 80;
server_name api.unisbadri.com;
location /api/resource {
# Run the rate limit check from an external Lua file
access_by_lua_file /etc/nginx/lua/rate_limiter.lua;
proxy_pass http://api_backend;
}
}
}
The /etc/nginx/lua/rate_limiter.lua Script Logic
#
-- Load the OpenResty built-in request limit module
local limit_req = require "resty.limit.req"
-- Create the limiter object: 50 requests per second limit, 20 request burst
local lim, err = limit_req.new("rate_limit_store", 50, 20)
if not lim then
ngx.log(ngx.ERR, "Failed to initialize the rate limiter: ", err)
return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
-- Identify the user based on a custom header (X-User-ID)
-- If absent, use the client's binary IP address as a fallback
local user_id = ngx.req.get_headers()["X-User-ID"] or ngx.var.binary_remote_addr
-- Evaluate the incoming request
local delay, err = lim:incoming(user_id, true)
if not delay then
if err == "rejected" then
# Burst limit exceeded (Too Many Requests)
ngx.status = 429
ngx.header.content_type = "application/json"
ngx.header["Retry-After"] = "1"
ngx.say('{"error": "Too Many Requests", "retry_after_seconds": 1}')
return ngx.exit(429)
end
ngx.log(ngx.ERR, "Error while processing the rate limit: ", err)
return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
# If the delay is a small positive value, we hold the request briefly (traffic shaping)
if delay >= 0.001 then
ngx.sleep(delay)
end
Production Case Example 4: Fast Caching Using Redis #
One of the most popular architecture patterns using OpenResty is caching dynamic response data directly at the gateway level using Redis. Nginx directly looks up data in the Redis RAM cache, completely bypassing the backend application if the cache is found (HIT).
server {
listen 8080;
location /get-product {
content_by_lua_block {
# 1. Load the built-in redis library
local redis = require "resty.redis"
local red = redis:new()
# Set timeouts: connect, send, read (in milliseconds)
red:set_timeouts(1000, 1000, 1000)
# 2. Connect to our local Redis server
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "Redis connection failed: ", err)
ngx.exit(500)
return
end
# 3. Look for a cache based on the product ID parameter in the URL
local args = ngx.req.get_uri_args()
local product_id = args["id"]
if not product_id then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say('{"error": "Missing product ID"}')
return
end
local cache_key = "product_cache:" .. product_id
local cached_data, err = red:get(cache_key)
if cached_data and cached_data ~= ngx.null then
# CACHE HIT: Directly return the response from Redis RAM
ngx.header.content_type = "application/json"
ngx.header.x_cache_status = "HIT_REDIS"
ngx.say(cached_data)
else
# CACHE MISS: Call the backend using an internal sub-request
ngx.header.x_cache_status = "MISS"
# Send the request to the internal backend upstream
local res = ngx.location.capture("/fallback-backend" .. ngx.var.request_uri)
if res.status == ngx.HTTP_OK then
# Store the new response data in Redis with a 10-minute TTL (600 seconds)
red:setex(cache_key, 600, res.body)
ngx.header.content_type = "application/json"
ngx.say(res.body)
else
ngx.status = res.status
ngx.say(res.body)
end
end
# 4. Return the connection to the connection pool (very important!)
# Maximum 10 seconds idle time, with a pool size of 100 connections
red:set_keepalive(10000, 100)
}
}
# The internal backend upstream that clients can't access directly
location /fallback-backend {
internal;
proxy_pass http://backend_app_upstream;
}
}
Writing Our Own Custom Lua Modules #
When our Lua codebase grows larger, putting code inside Nginx configuration files ruins the tidiness of our file architecture. We must separate logic into external custom Lua modules.
1. Create the Module File /usr/local/openresty/lualib/mycompany/utils.lua
#
local _M = {}
# Custom HTML string cleaner function
function _M.clean_string(str)
if not str then return nil end
# Remove suspicious HTML characters (XSS prevention)
return str:gsub("<", "<"):gsub(">", ">")
end
# Simple encryption function
function _M.obfuscate_email(email)
if not email then return nil end
local name, domain = email:match("([^@]+)@([^@]+)")
if not name or not domain then return email end
return name:sub(1, 2) .. "****@" .. domain
end
return _M
2. Calling the Module in Nginx #
We call the module above inside a location block using the require directive:
http {
# Add our custom directory to the Nginx library search path
lua_package_path "/usr/local/openresty/lualib/?.lua;;";
server {
listen 80;
location /process {
content_by_lua_block {
# Call our custom module
local utils = require "mycompany.utils"
local args = ngx.req.get_uri_args()
local email = args["email"]
local safe_email = utils.obfuscate_email(email)
ngx.header.content_type = "application/json"
ngx.say(string.format('{"original": "%s", "masked": "%s"}', email, safe_email))
}
}
}
}
Monitoring Shared Dictionary Performance (lua_shared_dict)
#
When we utilize shared RAM memory (lua_shared_dict) for rate limit recording, session data, or IP blacklists, we must monitor the remaining memory capacity so we don’t trigger data allocation failures.
Here’s a custom Lua endpoint to monitor our shared dictionary RAM status in real-time:
location /dict-stats {
allow 127.0.0.1;
deny all;
content_by_lua_block {
local shared_dict = ngx.shared.rate_limit_store
# Get the list of all active keys (requires the resty library)
local capacity = 10 * 1024 * 1024 # 10MB in bytes
local free_page_bytes = shared_dict:free_space()
local used_bytes = capacity - free_page_bytes
ngx.header.content_type = "application/json"
ngx.say(string.format(
'{"dict_name": "rate_limit_store", "capacity_bytes": %d, "used_bytes": %d, "free_bytes": %d, "utilization_percentage": %.2f}',
capacity, used_bytes, free_page_bytes, (used_bytes / capacity) * 100
))
}
}
When Should You Use Lua in Nginx? #
Although the Nginx Lua module is very powerful, it isn’t a silver bullet for solving all of our application’s problems. We must separate functionality wisely:
Scenarios Right for Lua in Nginx: #
- Centralized Authentication & Authorization: Checking JWT signatures, matching session cookies to a Redis database, or OAuth verification at the entry gate (Gateway) level.
- Dynamic Security Actions: Automatically blocking attacker IPs based on certain behavioral criteria in a shared RAM dict.
- Complex A/B Testing: Determining client upstream servers based on real-time multi-variable combinations (IP, country, browser OS type, cookie data).
- Streaming Header/Response Transformation: Inserting metadata, modifying JSON responses, or censoring sensitive data before sending to the client.
Scenarios Less Appropriate for Lua in Nginx: #
- Complex Business Logic: Processing payment transactions, heavy relational data processing, or generating PDFs. Move this work to our backend applications (Go, Node.js, Python) so the web server architecture stays clean.
- Heavy CPU-Bound Work: Custom image compression, video encryption, or complicated mathematical computations. CPU-bound loops will block the Nginx worker thread, destroying our web server’s overall concurrency throughput.
Summary and Best Practices #
- Always Return Connections to the Pool: After calling Redis (
red:connect) or MySQL, don’t forget to runred:set_keepalive(). Without this, Nginx keeps creating new TCP connections per request, triggering socket scarcity (socket exhaustion) on our database server.- Use open_by_lua_file in Production: For long code writing, separate the Lua code into an external file and use the
_filedirective to enable Lua code caching in RAM (lua_code_cache on).- Avoid Blocking OS Calls: Don’t use blocking standard library Lua functions like
io.open()oros.execute()inside Nginx location blocks. Use their openresty equivalents (likengx.threador resty-socket) so our server process stays non-blocking.