Centinel AnalyticaCentinel Analytica
PlatformsWeb Server

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-cjson and lua-resty-string, if you use nginx without OpenResty. OpenResty includes them.

Install

Install the module

On nginx without OpenResty, install lua-cjson first. Debian and Ubuntu:

apt install libnginx-mod-http-lua lua-cjson

Without 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.lua

Install the lua-resty-http dependency:

opm get ledgetech/lua-resty-http

No 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.lua

On 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.lua

Without lua-resty-string, nginx logs module 'resty.string' not found at startup.

Configure nginx.conf
# 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.

Set your secret key in the service environment

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 nginx

Docker

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-image

In 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 Centinel

Configuration reference

OptionTypeDefaultDescription
secret_keystring—API key from the dashboard. Required.
validator_urlstringhttps://validator.centinelanalytica.com/validateValidator endpoint.
timeout_msnumber100Request timeout (ms).
connect_timeout_msnumber100Connection timeout (ms).
fail_openbooleantrueAllow requests when API is unreachable.
keepalive_poolnumber64Idle validator connections each worker keeps open (lua-resty-http path).
ssl_verifybooleantrueVerify SSL certificates.
debugbooleanfalseVerbose logging.
log_enabledbooleantrueEnable all logging.
protected_pathstable{}Lua patterns to protect. Empty = protect all.
unprotected_pathstable[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/test

A 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

On this page