eggrd
Sheet 1 of 1  ·  Section through the request path  ·  Apache-2.0

The front door
your app shipped
without.

One static binary in front of your app. It owns the way in: TLS, authentication, rate limiting, input rules. It owns the way out too: redaction, hardened response headers, and hard ceilings on what your LLM calls may spend.

Nothing it refuses ever reaches your app. And nothing about your app changes to get that: no agent, no SDK, no library call.

Project
eggrd
Drawn as
Section A–A
Licence
Apache-2.0
Revision
v0.4.0 · 2026-09-14
Read the source Documentation

What each chamber does

Schedules · both directions
Request path schedule — inbound
StageConfigBehaviour
TLS[tls]Terminates TLS ✓ PROVEN: TLS 1.3, the certificate verified, plaintext refused on the TLS port, every hardening header applied through the tunnel. Three ways to hold a certificate: bring one, let [tls.acme] issue it ✓, or set self_signed = true and one is generated at first boot ✓, so TLS never fails to start for want of a file. ACME wins when both are configured.
HTTP → HTTPS[tls] redirect_portA plaintext listener answering 308, not 301, so a POST is replayed over TLS instead of being silently downgraded to a GET ✓. A forged Host gets 400 rather than a reflected redirect, and the ACME challenge path is never redirected.
Authentication[auth]Basic, API key or JWT, validated before the request reaches your app. Asserted, not assumed.
Rate limit[ratelimit]Per-IP and per-key ceilings. store = "redis" shares one ceiling across replicas ✓
Input rules[waf]WAF-lite inspection. Off by default — you turn it on deliberately.
IP access[access]allow and deny lists evaluated before anything downstream.
Response path schedule — outbound
StageConfigBehaviour
Headers[headers]CSP, HSTS and cookie hardening applied to what your app returns, plus Server and X-Powered-By stripped. Every header, listed.
Redaction[llm.dlp]Reversible masking on the LLM lane: the provider sees a placeholder, the caller gets their own values back. Detail B below.
Access log[log] queryQuery values are redacted by name and by shape, so ?api_key= and a bare JWT both log as <redacted> while page numbers and slugs stay readable. On by default since 0.4.0 ✓.
Compression[validation]compress_responses — negotiated response compression.
CORS preflight[cors]Preflight answered at the edge, with the allow-list held in config.

The request that never reached your app

Detail D · evaluation order

A gate that forwards the request and then returns the right status has failed at its job twice over: your app did the work, and on the LLM lane the provider was already paid. What matters is not the status code a client sees but whether anything behind eggrd was ever asked.

Until 0.4.0 that was an assertion in a comment. It is now a test. A counting stub stands in for your app, and every denial path asserts it saw exactly zero requests, with a control asserting an admitted request reaches it exactly once, so a counter that never incremented could not make the rest pass vacuously.

The five gates are drawn in evaluation order, and the last column of the schedule below is the one that matters.

The drawing is set to the 401 case. Every other refusal is in the schedule below.

Stopped at Auth Status 401 outcome unauthorized Your app saw 0 requests

Refusal schedule — every one of these is a test with a counting stub behind it
Refused atStatusoutcomeReached your app
IP access403ip_deniedno
Rate limit429rate_limitedno
Rate limit · store down503limiter_errorno, it fails closed rather than open
Auth401unauthorizedno
Body cap413payload_too_largeno
WAF403forbiddenno
Nothing — allowed200okyes, exactly once

The LLM lane carries the same guarantee and the same kind of test: over budget, an off-allow-list model, a DLP block and a budget-store outage each assert the provider was called zero times. Confirmed to have teeth by mutation: making a denial path call the provider before returning makes the test fail while the client still receives the identical status the old test accepted. Every code above is listed with its trigger in the API reference.

Three details, drawn larger

Callouts A · B · C
A

Shared-store rate limit

A per-process counter stops meaning anything the moment you run two replicas. eggrd can hold the limit in a store both replicas read, so the ceiling is the ceiling rather than the ceiling times your replica count.

[ratelimit] store = "redis" ✓ PROVEN

B

Reversible redaction

An irreversible [REDACTED] protects the provider's view and destroys yours. On the LLM lane eggrd keeps a per-request mask map: the provider sees a placeholder, and the response is unmasked back to that caller's own values — buffered or streamed.

[llm.dlp] mode = "redact" · reversible = true

C

Budgets that hold

A ceiling checked after the fact is a report, not a limit. eggrd reserves against the budget before the provider call and reconciles after, so a burst of concurrent requests cannot walk past the number together.

[[llm.budgets]] · fail-closed

The metered line

Detail C · LLM traffic

The same binary sitting in front of your app can sit in front of your model provider. It parses OpenAI-compatible traffic, counts input, output, cached and reasoning tokens separately, then prices them against a book you supply.

Metering is observe-only until you say otherwise: with no price book and no budgets it counts and reports, and changes nothing about the request. Turn on a budget and the line becomes a ceiling, enforced before the call rather than after the invoice.

An unknown model is a decision, not a default. on_unpriced_model either counts it and flags it, or rejects the request outright, so a model you never priced cannot quietly bill at zero.

# edgeguard.toml
[llm]
enabled = true
on_unpriced_model = "block"

[llm.models."gpt-4o"]
input_per_1m  = 2.5
output_per_1m = 10.0

[[llm.budgets]]
name  = "team-platform"
scope = "key"
unit  = "usd"
limit = 250.0
window = "30d"

[alerts]
budget_consumed_threshold = 0.8

Revision block

Seven run · one not deployed
ItemStateWhat that means
Request gates, in order ✓ PROVEN Auth, rate limiting, the WAF, IP access and the body cap are each asserted to reject before the upstream is contacted, not merely to return the right status: a counting stub stands in for the app and every denial path asserts it saw zero requests, with a control asserting an admitted one reaches it exactly once. Added in 0.4.0, and confirmed by mutation: making the auth path forward before rejecting fails the test while the client still sees the same 401.
Access-log query redaction ✓ PROVEN A request carrying ?token=…, ?api_key=… or a bare JWT logs as <redacted>, on by default, while the same request reaches the upstream, the WAF, rate limiting and DLP unredacted. One of the integration tests puts an SQL-injection payload in a parameter named code, which is on the redaction list, so the two behaviours are proved not to interfere.
TLS termination ✓ PROVEN TLS 1.3 negotiated, the certificate verified, the upstream response proxied back through it, plaintext refused on the TLS port, and all six hardening headers present through the tunnel.
Self-signed certificates ✓ PROVEN self_signed = true generated a certificate on first boot, rustls loaded it, curl --cacert got 200 through it and an untrusting client was refused — so it is real TLS, not a bypass. The key file's mode is asserted 0600. What it does not do is prove identity: right for localhost, a private network or staging, wrong for a public domain, where [tls.acme] is the answer and wins when both are configured.
HTTP → HTTPS redirect ✓ PROVEN Plaintext answered 308 to the TLS port with path and query intact; a POST followed the redirect and arrived upstream still a POST, with its body; a forged Host outside redirect_hosts got 400 and no Location; and /.well-known/acme-challenge/ got 404 rather than a redirect, because bouncing the token to the port whose certificate is being issued would deadlock the order.
Shared-store rate limiting ✓ PROVEN Exercised across two replicas sharing one Redis. Thirty requests from one client, alternating between replicas, were allowed 5 times, the configured burst, enforced once globally against a single key. The same run with store = "local" allowed 10, which is the documented per-replica behaviour and the reason the shared store exists.
ACME certificate issuance ✓ PROVEN, AND IT WAS BROKEN Not “untested and probably fine”: a two-year-old client could no longer parse the CA's authorization payload. Found by running it, fixed, and it now issues a real Let's Encrypt certificate in about five seconds, as CN=acme-test.eggrd.dev from a domain we control, and passes against Pebble locally. How to re-run it, and everything that was wrong.
WASM edge worker △ RUNS · NOT DEPLOYED Builds a deployable bundle and serves requests on workerd, the runtime Cloudflare runs in production: no credentials and a wrong password each returned 401, the correct one returned 200 from the origin carrying all six hardening headers, with Server and X-Powered-By stripped. It has not been deployed to a Cloudflare account, so routes, custom domains and secret bindings remain untested. This row stays red until that has been run.

Drawings carry a revision block because the honest state of a design is part of the design, and a row goes green here only after something has been run. Checking them is what changed them. The rate limiter passed. ACME turned out to be broken rather than merely untested. The WASM row once claimed more than its build could deliver and stayed marked unproven for as long as that was true; it now runs, and it is still not deployed, which is why it is the one red mark on this sheet. The runs are written down in docs/ so you can repeat them.

△ REVISION 0.4.0 one behaviour changes on upgrade. [log] query now defaults to redact, so a dashboard keyed on a ?campaign= value or a SIEM rule matching ?session= will find <redacted> there after upgrading. Set [log] query = "full" to keep the previous verbatim behaviour. Nothing else about the request, the routing or the upstream changes: only the log line is affected, and the upstream still receives the client's target byte for byte. The other two 0.4.0 additions, self-signed certificates and the redirect listener, stay off until you configure them.

Put it in front of something

Setup · four stacks
# install
cargo install eggrd

# scaffold a starter config beside your app, then check it before it sees traffic
edgeguard init
edgeguard doctor

The crate is eggrd; the binary it installs is still named edgeguard, an earlier working title kept so existing deployments keep working. init writes an annotated edgeguard.toml and a Dockerfile rather than a blank file, and refuses to clobber either. doctor then lints that config and walks the boot path, so a bad setting fails at your terminal instead of in front of traffic.

# or skip the toolchain entirely
docker run -p 8080:8080 \
  -v ./edgeguard.toml:/etc/edgeguard/edgeguard.toml \
  mancube/eggrd:0.4.0

A static musl binary on distroless: no shell and no package manager inside it to attack. linux/amd64 and linux/arm64, each built on its own native runner. 0.4.0 more than halved the binary, from 16.6 MiB to 7.5 MiB, by dropping aws-lc-sys, which nothing used: it rode in on feature unification, and it was the build's only C dependency, the piece most likely to break a musl or cross target.

There are two shapes, and init picks the right one for what it finds. Either eggrd becomes your container's entrypoint and runs your app as a child on APP_PORT, or it runs as its own service pointed at an upstream URL. Neither needs a line of your application code.

Pick the stack you are on. Every command below is from the example files in the repository, not an illustration of them.

Node

# in your app's repo
edgeguard init                    # detects Node, writes the config + a Dockerfile
echo -n 'your-password' | edgeguard --hash

# eggrd takes the public port; your app moves to APP_PORT
PORT=8080 APP_PORT=3000 edgeguard --config edgeguard.toml \
  --wrap "node server.js"

Your app keeps reading its port from the environment: eggrd sets PORT=$APP_PORT for the child process, so server.js needs no edit. What changes is which process owns the public port.

The Dockerfile.edgeguard that init writes copies the binary out of the published image and makes it the entrypoint, so the deployed container has the same front door as your laptop.

Python

# in your app's repo
edgeguard init                    # detects Python
edgeguard doctor

PORT=8080 APP_PORT=3000 edgeguard --config edgeguard.toml \
  --wrap "uvicorn app.main:app --host 127.0.0.1 --port $APP_PORT"

The wrap command runs through sh -c, so $APP_PORT expands inside it. Bind the app to 127.0.0.1: once eggrd owns the public port there is no reason for the app to be reachable on its own.

Gunicorn, Django and Flask take the same shape. Only the command inside --wrap changes.

Docker Compose

services:
  edge:
    image: mancube/eggrd:0.4.0
    ports: ["8080:8080"]
    environment:
      UPSTREAM: http://app:3000
      EDGEGUARD_JWT_SECRET_FILE: /run/secrets/jwt_secret
    command: ["--config", "/etc/edgeguard/edgeguard.toml"]
    volumes:
      - ./edgeguard.toml:/etc/edgeguard/edgeguard.toml:ro
    secrets: [jwt_secret]

  app:
    image: ghcr.io/example/your-app:latest
    expose: ["3000"]          # internal only, never published

secrets:
  jwt_secret:
    file: ./secrets/jwt_secret.txt

The app is exposed rather than ports-published, so it is reachable only through eggrd. That is the whole point of a front door, and it is a property of the compose file rather than of any code you wrote.

The JWT secret arrives as a file. Every secret variable has a *_FILE twin, the convention Docker and Kubernetes mounts already use, so the value never lands in the config file or in a ps-visible environment. A mount that cannot be read is a hard startup error rather than a proxy quietly running with no secret.

Kubernetes

env:
  - name: UPSTREAM
    value: http://app.default.svc:3000
  - name: ADMIN_PORT              # ops endpoints, off the public port
    value: "9090"
  - name: EDGEGUARD_JWT_SECRET_FILE
    value: /run/secrets/eggrd/jwt
volumeMounts:
  - name: eggrd-secrets
    mountPath: /run/secrets/eggrd
    readOnly: true

livenessProbe:
  httpGet: { path: /__edgeguard/health, port: 9090 }
readinessProbe:
  httpGet: { path: /__edgeguard/ready, port: 9090 }

ADMIN_PORT moves health, readiness and metrics onto a private listener, so the ops trio is not answering on the port the internet reaches. Keep it on the pod network; it carries no authentication by design.

The two probes answer different questions on purpose. Liveness consults nothing, so a restarting upstream never kills the proxy in front of it. Readiness reflects the upstream, so a pod whose app is down leaves the Service instead of accepting traffic it cannot serve.

Somewhere else? Fly.io, Render, Railway and a hardened systemd unit each have a shipped example file in examples/, and every environment variable they set is listed in the CLI reference. Static and edge hosts, where no long-lived proxy can run, are the one shape this does not fit: use edgeguard generate to emit the hardening config, or the Cloudflare Worker.

Where the reference lives

Sheet index · eggrd.dev/docs

Answering “what does this key actually do?” used to mean opening config.rs. It does not any more. Five pages carry the whole surface, and the largest of them is generated from the source so it cannot describe a binary that does not exist.

A field added without a doc comment fails the generator rather than producing a blank row: a reference with silent holes in it looks complete and is not. Writing it found 21 public config fields with no doc comment at all, which are now documented in the source as well as on the page.

Sheet index
PageWhat it carriesKept honest by
OverviewWhere to start, and how the pages differ.By hand
CLI & environmentEvery subcommand and flag, the environment variables that override the config, the one optional build feature.Checked against --help
ConfigurationAll 151 keys across 21 tables, with types and defaults.Generated from src/config.rs
API & data planeThe reserved endpoints, the headers eggrd adds to the request and to the response, and every status code it generates instead of your app, in evaluation order.Read from the routers and the pipeline
OperationsHealth, readiness and metrics endpoints, every Prometheus series, and what fails closed.By hand