When you run ngris http 3000, you get a public HTTPS URL in a couple of seconds. What is less obvious is that the same edge that terminates that TLS connection can also run a full traffic-policy engine in front of your service — the request filtering, authentication, transformation, and resilience logic you would normally stand up a separate API gateway for.
This post is a tour of that engine: the phases rules run in, the exact set of rule types available today, how the CEL guard language and the per-request variable bus work, and — just as important — where the honest limits are.
Rules run in ordered phases
A traffic policy is a bag of rules, and every rule declares a phase — the point in the connection lifecycle where it runs. There are exactly four:
on_tcp_connectandon_udp_connect— the layer-4 phases. At this point there is no HTTP request yet, so only two rule types are offered here:restrict-ipsandrate-limit. These let you drop a connection before you have paid the cost of parsing anything.on_http_request— the busy one. This is where authentication, the WAF, bot management, CORS, body validation, redirects, and header rewrites all run, before the request is forwarded to your origin.on_http_response— runs on the way back out: add or strip response headers, replace or compress the body, or map an upstream status onto a custom error page.
Phase is stored per rule, not per policy, so one policy can carry an IP restriction at connect time and a JWT check at request time. add-headers and remove-headers are the flexible pair — they run in both the request and response phases. The security and enforcement types, on the other hand, are pinned to the request phase: try to save a jwt-validation, waf, bot-management, validate-body, require-client-cert (or the other request-only types) in a different phase and the API rejects it with a 400. That is deliberate — an enforcement rule saved in a phase where it never runs would be a silent fail-open bypass, which is the worst kind of security bug.
First non-continue result wins
Within a phase, rules are evaluated in a fixed order — policy priority, then rule priority, then age — and the engine walks the list top to bottom. Each rule produces an action, and the possible actions are continue, redirect, custom_response, deny, and cached_response. Only continue keeps the loop going. The first rule that returns anything other than continue wins and short-circuits the whole evaluation.
That single sentence is the mental model. A restrict-ips rule that denies returns deny and nothing after it runs. A rule that does not apply — because its condition did not match, or because it is a no-op on the connect phase — returns continue and the next rule gets a turn. Order matters, and it is stable and predictable.
Two honest caveats. First, the response phase does not short-circuit: all matching add-headers, remove-headers, and CORS response headers are applied, because it makes no sense for one header rule to cancel another. Second, replace-body, compress-response, and custom-error are handled by separate response engines rather than the main first-match loop, so they do not compete with each other for a single winning action either.
Twenty-three rule types, grouped by job
There are exactly 23 rule types today. (If you have read an older doc that said 18, it was stale.) Rather than list them alphabetically, here they are grouped by what they are for.
Security and identity — the gate. restrict-ips (CIDR allow/deny), jwt-validation (verify a signed JWT), api-key-validation, require-client-cert (mTLS), verify-webhook (validate an inbound webhook signature), validate-body (JSON-Schema request bodies), force-https, cors, waf (a Coraza/ModSecurity-compatible web application firewall), and bot-management (JA3 TLS fingerprint allow/deny). The enforcement types here fail closed on corrupt or missing config — a broken jwt-validation rule denies rather than waves traffic through.
Transforms — reshape the request. add-headers, remove-headers, url-rewrite, redirect, and set-vars (more on that below).
Resilience — keep the origin healthy. rate-limit, circuit-breaker (trip open after an error threshold and shed load during a cooldown), cache-response, and webhook (call out to an external service as part of the request).
Response shaping — control what goes back to the client. custom-response, custom-error, replace-body, and compress-response (gzip when the client sent Accept-Encoding: gzip).
A rule's config is a small JSON object. Here is a bot-management rule that logs the JA3 fingerprints of a couple of known scrapers without blocking anything yet:
# one rule inside a traffic policy
{
"rule_type": "bot-management",
"phase": "on_http_request",
"name": "Watch scraper fingerprints",
"enabled": true,
"config": {
"mode": "detect",
"ja3_denylist": ["e7d705a3286e19ea42f587b344ee6865"],
"block_missing": false
}
}
CEL guards: run this rule only when…
Every rule can carry an optional expression — a CEL boolean that gates whether the rule fires at all. If the expression is empty, the rule always fires. If it is present, it is evaluated first: only when it returns true does the rule's action run; on a non-match or an evaluation error the rule is skipped and evaluation continues to the next one.
Crucially, the same CEL environment is shared between the live edge and the save-time validator, so an expression that compiles when you save it means the same thing when it runs. Three top-level variables are available:
req—req.method,req.path,req.host,req.query,req.scheme,req.user_agent, plus the mapsreq.headers(lowercased names, first value),req.cookies, andreq.query_params.conn—conn.client_ip(the PROXY-resolved client address) andconn.ja3(the client's TLS JA3 fingerprint, or""when unavailable).vars— the per-request variable bus, written byset-varsrules.
# examples of valid guard expressions
req.method == "POST"
req.path.startsWith("/admin")
req.headers["x-api-tier"] == "free"
conn.client_ip == "203.0.113.4" && req.method != "GET"
conn.ja3 == "e7d705a3286e19ea42f587b344ee6865"
One behavior worth knowing precisely: indexing an absent key on req.headers, req.cookies, req.query_params, or vars returns the empty string, not an error. So req.headers["x-missing"] == "" is true when the header is absent — the guard does not blow up and silently skip. If you want membership semantics instead, "x-missing" in req.headers still correctly returns false. Expressions are capped at 2048 bytes and must type as boolean, or the save is rejected.
The set-vars bus: compute once, read later
The set-vars rule type is the plumbing that makes multi-step policies readable. It takes a map of variable name to CEL expression, evaluates each expression's string result, and stores it on a per-request bus. set-vars always returns continue — it never blocks — and later rules read the values two ways: through vars.<name> in a CEL guard, or through ${vars.<name>} interpolation inside an add-headers header value.
# a set-vars rule feeding later rules
{
"rule_type": "set-vars",
"phase": "on_http_request",
"config": {
"vars": {
"tier": "req.headers[\"x-api-tier\"]",
"client": "conn.client_ip"
}
}
}
A later rate-limit rule can then guard on vars.tier == "free", and an add-headers rule can forward X-Rate-Tier: ${vars.tier} to your origin. Entries are evaluated in sorted name order for determinism, values are stripped of CR/LF/NUL to defend against header injection, and a bad expression stores an empty string rather than failing the request. Note the honest scope: ${...} interpolation is wired into add-headers header values only — it is not a general template language for redirect URLs or response bodies.
Safe by default
The security-heavy rule types are built so that turning them on cannot break your traffic on day one.
- The WAF defaults to
detect— it logs a match and lets the request through. You watch the logs, confirm you are not catching legitimate traffic, then flipmodetoblock. Its default ruleset is a curated in-binary baseline (core) covering high-signal classes like SQLi, XSS, path traversal, and Log4Shell — not the full OWASP CRS, which you can paste in as custom SecLang if you want it. - Bot management defaults to
detecttoo, andblock_missingis off — a request with no computable fingerprint is allowed, because a missing fingerprint is not proof of a bot. The intended workflow is to run in detect, copy the exact 32-hex JA3 values out of the log lines, then denylist or allowlist them and switch toblock. - OpenAPI import stages its rules disabled. Upload or paste an OpenAPI 3 spec and the gateway generates one
validate-bodyrule per JSON-body operation — but in the default detect mode they are created withenabled: false, so an import can never start rejecting live traffic until you re-import with enforce mode.
# a WAF rule you can enable without fear
{
"rule_type": "waf",
"phase": "on_http_request",
"config": {
"mode": "detect",
"ruleset": "core",
"inspect_body": true,
"max_body_kb": 128
}
}
Where the honest limits are
A few things this engine deliberately does not do, so you are not surprised:
- Bot management is JA3-only, allow/deny only. There is no JA4 fingerprinting, and there is no CAPTCHA, JS challenge, or interstitial — the only outcomes are allow or a plain 403. JA3 is computed from the TLS ClientHello on the TCP path, so there is no QUIC/HTTP-3 fingerprinting.
- The WAF fails open. If the engine cannot build (for example, a broken custom ruleset), the endpoint is allowed through and logged as unprotected — availability is preserved at the cost of enforcement. api-server does not link Coraza, so custom SecLang is checked against a denylist and size limit at save time, but not fully compiled until it reaches the data plane.
- The header, cookie, and JWT rate-limit scopes are for fair-sharing, not abuse protection. They bucket on client-controlled values, and the JWT scope reads a claim without verifying the signature. Use the IP scope when you need an actual defensive limit.
- The
webhookrule fails open by default on a transport failure to a valid URL, to avoid making an external dependency a hard availability dependency. There is an opt-inFailClosedknob if you want the opposite.
Policies attach per endpoint
Traffic policies attach to a single endpoint, and an endpoint can carry several. On the hot path the edge never touches the database per request — the enabled policies and rules for an endpoint are served from an in-memory bundle that is rebuilt on reload. That reload is itself fail-safe: if the policy load hits a transient database error, the edge keeps the previous cache rather than swapping in an empty one, so a database blip never drops every endpoint's security rules fleet-wide.
Which rule types you can create is gated by your plan. Each type maps to a traffic_rule:<type> entitlement checked at create time and scoped to your account; a type your plan does not include returns a 403 at save with a clear message. Everything else is just rules — ordered, guarded, and evaluated at the edge, first non-continue result wins.
Turn a tunnel into a gateway
Expose a service, then attach a policy from the dashboard or API:
ngris http 3000
Start every security rule in detect mode, read the logs, then flip to block.