Nginx / OpenResty
Bot protection for Nginx and OpenResty using a Lua module.
Overview
Centinel protects Nginx and OpenResty using a Lua module that intercepts every request in the access_by_lua_block phase, validates it against the Centinel API, and enforces the returned decision before your backend sees the traffic.
Prerequisites
- Secret key from your dashboard
- OpenResty 1.19+ or nginx with lua-nginx-module
lua-cjsonandlua-resty-string, if you use nginx without OpenResty. OpenResty includes them.
Install
On nginx without OpenResty, install lua-cjson first. Debian and Ubuntu:
apt install libnginx-mod-http-lua lua-cjsonWithout lua-cjson, nginx logs module 'cjson.safe' not found at startup.
curl -o /usr/local/openresty/site/lualib/centinel-nginx.lua \
https://docs.centinelanalytica.com/downloads/centinel-nginx.luaInstall the lua-resty-http dependency:
opm get ledgetech/lua-resty-httpNo opm? Install manually:
mkdir -p /usr/local/openresty/site/lualib/resty
curl -o /usr/local/openresty/site/lualib/resty/http.lua \
https://raw.githubusercontent.com/ledgetech/lua-resty-http/master/lib/resty/http.lua
curl -o /usr/local/openresty/site/lualib/resty/http_headers.lua \
https://raw.githubusercontent.com/ledgetech/lua-resty-http/master/lib/resty/http_headers.lua
curl -o /usr/local/openresty/site/lualib/resty/http_connect.lua \
https://raw.githubusercontent.com/ledgetech/lua-resty-http/master/lib/resty/http_connect.luaOn nginx without OpenResty, also install lua-resty-string. Debian 12 does not package it:
curl -o /usr/local/openresty/site/lualib/resty/string.lua \
https://raw.githubusercontent.com/openresty/lua-resty-string/master/lib/resty/string.luaWithout lua-resty-string, nginx logs module 'resty.string' not found at startup.
# At the top, before events {} — required for os.getenv() to work in OpenResty
env CENTINEL_SECRET_KEY;
env CENTINEL_VALIDATOR_URL;
# Debian and Ubuntu: loads the Lua module. Keep this line from the stock nginx.conf
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 1024;
}
http {
lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
lua_ssl_verify_depth 3;
# Older lua-nginx-module releases (Debian 12) default to TLS 1.2 or lower
lua_ssl_protocols TLSv1.2 TLSv1.3;
resolver 8.8.8.8 valid=30s ipv6=off;
lua_shared_dict centinel_cache 10m;
lua_package_path '/usr/local/openresty/site/lualib/?.lua;;';
init_by_lua_block {
centinel = require("centinel-nginx")
centinel.init({
secret_key = os.getenv("CENTINEL_SECRET_KEY"),
validator_url = os.getenv("CENTINEL_VALIDATOR_URL")
or "https://validator.centinelanalytica.com/validate",
timeout_ms = 1000,
connect_timeout_ms = 500
})
}
server {
listen 80;
location / {
access_by_lua_block { centinel.access_handler() }
proxy_pass http://your-backend;
}
}
}Do not use return as your content directive
return 200 runs in the rewrite phase, before access_by_lua_block. Centinel will never execute. Use proxy_pass or content_by_lua_block instead.
An nginx -s reload command does not copy environment variables from your shell into the running master process. Set the key in the service that starts nginx.
For systemd, create a unit drop-in:
sudo systemctl edit nginx[Service]
Environment="CENTINEL_SECRET_KEY=YOUR_SECRET_KEY"Then reload the unit configuration and restart nginx:
sudo systemctl daemon-reload
sudo systemctl restart nginxDocker
FROM openresty/openresty:alpine
RUN apk add --no-cache ca-certificates
RUN mkdir -p /usr/local/openresty/site/lualib/resty && \
wget -q -O /usr/local/openresty/site/lualib/resty/http.lua \
https://raw.githubusercontent.com/ledgetech/lua-resty-http/master/lib/resty/http.lua && \
wget -q -O /usr/local/openresty/site/lualib/resty/http_headers.lua \
https://raw.githubusercontent.com/ledgetech/lua-resty-http/master/lib/resty/http_headers.lua && \
wget -q -O /usr/local/openresty/site/lualib/resty/http_connect.lua \
https://raw.githubusercontent.com/ledgetech/lua-resty-http/master/lib/resty/http_connect.lua
RUN wget -q -O /usr/local/openresty/site/lualib/centinel-nginx.lua \
https://docs.centinelanalytica.com/downloads/centinel-nginx.lua
COPY nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
EXPOSE 80
CMD ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]docker run -e CENTINEL_SECRET_KEY="sk_live_xxx" -p 80:80 your-imageIn a Docker Compose network, set resolver 127.0.0.11 valid=30s ipv6=off; instead of 8.8.8.8. Docker's embedded DNS at 127.0.0.11 handles both container names and external hostnames.
Configure
All paths are protected by default except static assets. Override in centinel.init():
centinel.init({
secret_key = os.getenv("CENTINEL_SECRET_KEY"),
protected_paths = { "^/api/", "^/admin" }, -- empty = protect all
unprotected_paths = { "^/health$", "^/metrics$" }
})Excluded by default: images (.gif, .ico, .jpg, .jpeg, .png, .svg, .webp, .avif, .bmp), fonts (.eot, .otf, .ttf, .woff, .woff2), styles (.css, .less), scripts (.js, .map), media (.mp3, .mp4, .webm, .wav, .flac, and others), archives (.gz, .zip), and .json, .xml.
Uses nginx's native proxy to call the validator. Connections to the upstream are pooled and reused, so each request skips the TLS handshake overhead.
Add to the http {} block:
upstream centinel_validator {
server validator.centinelanalytica.com:443;
keepalive 64;
keepalive_requests 1000;
keepalive_timeout 60s;
}Add to the server {} block:
set $centinel_h2_enabled "1";
location = /_centinel_validate {
internal;
set $centinel_api_key "";
set $centinel_version "";
rewrite_by_lua_block {
ngx.var.centinel_api_key = ngx.ctx.centinel_api_key or ""
ngx.var.centinel_version = ngx.ctx.centinel_version or ""
}
proxy_pass https://centinel_validator/validate;
proxy_http_version 1.1;
proxy_pass_request_headers off;
proxy_set_header Host "validator.centinelanalytica.com";
proxy_set_header Content-Type "application/json";
proxy_set_header Connection "";
proxy_set_header x-api-key $centinel_api_key;
proxy_set_header x-origin-module "nginx";
proxy_set_header x-origin-version $centinel_version;
# nginx before 1.23.4 also offers TLS 1.0 and 1.1 by default
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_server_name on;
proxy_ssl_name validator.centinelanalytica.com;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_ssl_session_reuse on;
proxy_connect_timeout 1s;
proxy_send_timeout 2s;
proxy_read_timeout 2s;
}When $centinel_h2_enabled is set, the module uses this location. Without it, it falls back to lua-resty-http. Only the lua-resty-http path uses the resolver directive. This location gets the upstream address when nginx starts.
proxy_pass_request_headers off stops nginx from sending the visitor's request headers to the validator a second time. The module already sends them in the request body.
Set keepalive to at least the number of validator requests that one worker has open at the same time. When more requests are open than keepalive allows, nginx closes the extra connections after use. The next request then does a new TLS handshake.
validator_url does not apply on this path
Requests routed through this location go to the upstream block above, so validator_url and CENTINEL_VALIDATOR_URL are ignored. The module still logs the configured URL, which makes the mismatch easy to miss. To point at a different validator, change the upstream server plus the Host and proxy_ssl_name values here.
centinel.init({
secret_key = os.getenv("CENTINEL_SECRET_KEY"),
timeout_ms = 1000, -- default: 100 (increase if not using the keepalive pool)
connect_timeout_ms = 500, -- default: 100
fail_open = true -- default: true (allow requests if API is down)
})On API failure the module backs off exponentially: 1s → 2s → 4s → 8s → 5min max. Requests pass through during backoff.
centinel.init({
secret_key = os.getenv("CENTINEL_SECRET_KEY"),
debug = true
})Or set CENTINEL_DEBUG=true as an environment variable (requires env CENTINEL_DEBUG; in nginx.conf).
Check the error log for [Centinel] entries:
tail -f /var/log/nginx/error.log | grep CentinelConfiguration reference
| Option | Type | Default | Description |
|---|---|---|---|
secret_key | string | — | API key from the dashboard. Required. |
validator_url | string | https://validator.centinelanalytica.com/validate | Validator endpoint. |
timeout_ms | number | 100 | Request timeout (ms). |
connect_timeout_ms | number | 100 | Connection timeout (ms). |
fail_open | boolean | true | Allow requests when API is unreachable. |
keepalive_pool | number | 64 | Idle validator connections each worker keeps open (lua-resty-http path). |
ssl_verify | boolean | true | Verify SSL certificates. |
debug | boolean | false | Verbose logging. |
log_enabled | boolean | true | Enable all logging. |
protected_paths | table | {} | Lua patterns to protect. Empty = protect all. |
unprotected_paths | table | [static assets] | Lua patterns to skip. |
Verify
nginx -t does not execute init_by_lua_block, so a successful config test does not prove the
module loaded. Reload, then check both a log line and a real request:
nginx -t && nginx -s reload
tail -f /var/log/nginx/error.log | grep Centinel # expect: Centinel module initialized
curl -s -o /dev/null -w '%{http_code}\n' -A 'python-requests/2.31' http://localhost/api/testA 500 on the first request usually means lua-resty-http is missing or the module is not on
lua_package_path. Without a lua_shared_dict centinel_cache, the module logs a warning and runs
with backoff disabled.
Changelog
- v1.1.3 — Validator connection reuse
- v1.1.2 — HTTP/2 fallback fix
- v1.1.1 — Response header passthrough
- v1.1.0 — Cookie and proxy fixes
- v1.0.0 — Initial release