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

Install

Install the module
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
Configure nginx.conf
# At the top, before events {} — required for os.getenv() to work in OpenResty
env CENTINEL_SECRET_KEY;
env CENTINEL_VALIDATOR_URL;

events {
    worker_connections 1024;
}

http {
    lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
    lua_ssl_verify_depth 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 16;
    keepalive_requests 1000;
    keepalive_timeout 60s;
}

Add to the server {} block:

set $centinel_h2_enabled "1";

location = /_centinel_validate {
    internal;

    rewrite_by_lua_block {
        ngx.req.set_header("x-api-key",        ngx.ctx.centinel_api_key or "")
        ngx.req.set_header("x-origin-module",  "nginx")
        ngx.req.set_header("x-origin-version", ngx.ctx.centinel_version or "")
    }

    proxy_pass              https://centinel_validator/validate;
    proxy_http_version      1.1;

    proxy_set_header        Host         "validator.centinelanalytica.com";
    proxy_set_header        Content-Type "application/json";
    proxy_set_header        Connection   "";

    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.

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_keystringAPI 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.
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.1 — Response header passthrough
  • v1.1.0 — Cookie and proxy fixes
  • v1.0.0 — Initial release

On this page