# Nginx / OpenResty

> Bot protection for Nginx and OpenResty using a Lua module.

Source: https://docs.centinelanalytica.com/platforms/web-server/nginx

## 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](https://docs.centinelanalytica.com/install/dashboard.md)
* 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

1. **Install the module**

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

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

   Without `lua-cjson`, nginx logs `module 'cjson.safe' not found` at startup.

   ```bash
   curl -o /usr/local/openresty/site/lualib/centinel-nginx.lua \
     https://docs.centinelanalytica.com/downloads/centinel-nginx.lua
   ```

   Install the `lua-resty-http` dependency:

   ```bash
   opm get ledgetech/lua-resty-http
   ```

   No `opm`? Install manually:

   ```bash
   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:

   ```bash
   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.

2. **Configure nginx.conf**

   ```nginx
   # 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.

3. **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:

   ```bash
   sudo systemctl edit nginx
   ```

   ```ini
   [Service]
   Environment="CENTINEL_SECRET_KEY=YOUR_SECRET_KEY"
   ```

   Then reload the unit configuration and restart nginx:

   ```bash
   sudo systemctl daemon-reload
   sudo systemctl restart nginx
   ```

### Docker

```dockerfile
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;"]
```

```bash
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

**Path protection**

All paths are protected by default except static assets. Override in `centinel.init()`:

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

**Keepalive pool**

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:

```nginx
upstream centinel_validator {
    server validator.centinelanalytica.com:443;
    keepalive 64;
    keepalive_requests 1000;
    keepalive_timeout 60s;
}
```

Add to the `server {}` block:

```nginx
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.

**Timeouts**

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

**Debug**

```lua
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:

```bash
tail -f /var/log/nginx/error.log | grep Centinel
```

### Configuration 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:

```bash
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
