Centinel AnalyticaCentinel Analytica
Platforms

Apache HTTP Server

Bot protection for Apache HTTP Server using a Lua module with mod_lua.

Prerequisites

  • Secret key from your dashboard
  • Apache 2.4+ with mod_lua enabled
  • Lua 5.1 libraries: lua-curl, lua-socket, lua-cjson

Debian/Ubuntu users

The lua-curl package from apt does not work with mod_lua. It installs, but the shared object file that require("lcurl") needs is never created. Follow the luarocks install method in the Debian/Ubuntu tab below.

Installation

Download and extract the module

Download centinel-apache.zip and extract both files to your Lua module directory:

curl -O https://docs.centinelanalytica.com/downloads/centinel-apache.zip
unzip centinel-apache.zip -d /usr/local/lib/lua/

The zip contains:

  • centinel-apache.lua - core module
  • centinel-handler.lua - Apache hook entry point
Install dependencies

On Alpine Linux:

apk add apache2-lua lua5.1-curl lua5.1-socket lua5.1-cjson

On Debian/Ubuntu:

apt-get install libapache2-mod-lua lua-socket lua-cjson luarocks libcurl4-openssl-dev lua5.1-dev
a2enmod lua
luarocks install Lua-cURLv3 LUA_VERSION=5.1 CURL_INCDIR=/usr/include

Why luarocks instead of apt for lcurl

The Debian lua-curl apt package does not install the lcurl.so shared object that mod_lua loads via require("lcurl"). luarocks builds it directly. The LUA_VERSION=5.1 flag is required because luarocks compiles against Lua 5.3/5.4 headers by default, but mod_lua uses Lua 5.1. Without it, Apache fails to load the module with undefined symbol: lua_tointeger.

Configure httpd.conf
# Load mod_lua
LoadModule lua_module modules/mod_lua.so

# Lua package paths (adjust for your distro)
LuaPackagePath /usr/share/lua/5.1/?.lua
LuaPackagePath /usr/share/lua/5.1/?/init.lua
LuaPackageCPath /usr/lib/lua/5.1/?.so

# Centinel module path
LuaPackagePath /usr/local/lib/lua/?.lua

# Pass environment variables to Lua
PassEnv CENTINEL_SECRET_KEY
PassEnv CENTINEL_VALIDATOR_URL
PassEnv CENTINEL_DEBUG

# Persist Lua state across requests (required for connection reuse)
LuaScope server

# Centinel access checker — runs on every request
LuaHookAccessChecker /usr/local/lib/lua/centinel-handler.lua access_check early

LuaScope server is required

Without LuaScope server, Apache creates a fresh Lua state per request. The module reuses a persistent HTTP/2 connection to the validator API. Dropping that state on every request forces a new TLS handshake per validation.

Set your secret key and restart
export CENTINEL_SECRET_KEY="sk_live_your_key_here"
apachectl configtest && apachectl restart

For systemd:

[Service]
Environment="CENTINEL_SECRET_KEY=sk_live_your_key_here"

Docker

FROM alpine:3.21

RUN apk add --no-cache \
    apache2 \
    apache2-lua \
    apache2-proxy \
    lua5.1-curl \
    lua5.1-socket \
    lua5.1-cjson \
    ca-certificates

RUN mkdir -p /run/apache2

RUN wget -q -O /tmp/centinel-apache.zip \
    https://docs.centinelanalytica.com/downloads/centinel-apache.zip && \
    unzip /tmp/centinel-apache.zip -d /var/www/localhost/lua/ && \
    rm /tmp/centinel-apache.zip

COPY httpd.conf /etc/apache2/httpd.conf

EXPOSE 80
CMD ["httpd", "-D", "FOREGROUND", "-f", "/etc/apache2/httpd.conf"]
docker run -e CENTINEL_SECRET_KEY="sk_live_xxx" -p 80:80 your-image

Advanced configuration

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.

centinel.init({
    secret_key = os.getenv("CENTINEL_SECRET_KEY"),
    timeout_ms = 2000,          -- default: 2000
    connect_timeout_ms = 2000,  -- default: 2000
    fail_open = true            -- default: true (allow requests if API is down)
})

On API failure the module backs off exponentially: 1s, 2s, 4s, 8s up to 5 minutes 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 PassEnv CENTINEL_DEBUG in httpd.conf).

Check the error log for [Centinel] entries:

tail -f /var/log/apache2/error.log | grep Centinel

Replace centinel-handler.lua with your own handler to control initialization directly:

local centinel = require("centinel-apache")

local initialized = false

function access_check(r)
    if not initialized then
        centinel.init({
            secret_key = r.subprocess_env["CENTINEL_SECRET_KEY"]
                or os.getenv("CENTINEL_SECRET_KEY"),
            validator_url = os.getenv("CENTINEL_VALIDATOR_URL")
                or "https://validator.centinelanalytica.com/validate",
            timeout_ms = 2000,
            fail_open = true,
            debug = false,
            protected_paths = { "^/api/", "^/admin" },
            unprotected_paths = { "^/health$", "^/metrics$" }
        })
        initialized = true
    end

    return centinel.access_handler(r)
end

Point LuaHookAccessChecker to your custom handler file.


Troubleshooting

module 'lcurl' not found

The lua-curl apt package on Debian/Ubuntu installs without producing lcurl.so, so require("lcurl") fails. Install via luarocks instead:

apt-get install luarocks libcurl4-openssl-dev lua5.1-dev
luarocks install Lua-cURLv3 LUA_VERSION=5.1 CURL_INCDIR=/usr/include

undefined symbol: lua_tointeger

Full error:

error loading module 'lcurl' from file '/usr/local/lib/lua/5.1/lcurl.so':
    /usr/local/lib/lua/5.1/lcurl.so: undefined symbol: lua_tointeger

luarocks compiled lcurl against Lua 5.3 or 5.4 headers, but mod_lua uses Lua 5.1. Reinstall with the correct target:

luarocks remove Lua-cURLv3
luarocks install Lua-cURLv3 LUA_VERSION=5.1 CURL_INCDIR=/usr/include

Module loads but requests time out or fail

Check that LuaScope server is set in httpd.conf. Without it, Apache creates a fresh Lua state per request, forcing a new TLS handshake to the validator on every hit. That causes severe latency and request timeouts under load.

LuaScope server

Failover not triggering during an API outage

Update to v1.2.0 or later. Earlier versions stored backoff state in the Lua runtime, which Apache resets on every request. The counter never reached the threshold, so failover never engaged and requests failed instead of failing open.

Check your installed version:

grep -i version /usr/local/lib/lua/centinel-apache.lua

How to check logs

Enable debug logging and tail the Apache error log:

tail -f /var/log/apache2/error.log | grep Centinel

Enable debug mode via config:

centinel.init({
    secret_key = os.getenv("CENTINEL_SECRET_KEY"),
    debug = true
})

Or via environment variable (requires PassEnv CENTINEL_DEBUG in httpd.conf):

export CENTINEL_DEBUG=true

Configuration reference

OptionTypeDefaultDescription
secret_keystringAPI key from the dashboard. Required.
validator_urlstringhttps://validator.centinelanalytica.com/validateValidator endpoint.
timeout_msnumber2000Request timeout (ms).
connect_timeout_msnumber2000Connection 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.

Environment variables

VariableDescription
CENTINEL_SECRET_KEYAPI key. Required.
CENTINEL_VALIDATOR_URLCustom validator endpoint.
CENTINEL_DEBUGSet to true or 1 for debug logging.

All three require PassEnv directives in httpd.conf to be accessible from Lua.


Changelog

v1.2.0

  • Fixed failover/backoff logic for Apache's per-request worker model. Previous versions stored backoff state in the Lua runtime, which reset on every request. Backoff state now persists correctly, ensuring fail-open behavior during upstream outages.
  • Improved error handling and logging for connection failures.

v1.1.1

  • Response headers from /validate are now applied to all outgoing responses (block, redirect, and allow). Headers like Content-Type are no longer hardcoded — they come from the validator.
  • Validator API requests now include a User-Agent header identifying the integration name and version.
  • 1.1.0 – Switched HTTP client to lcurl for HTTP/2 support and persistent connection reuse.
  • 1.0.0 – Initial release.

On this page