# GroundShade documentation
> Self-hosted Rust reverse proxy for DDoS and scraper defense. Every doc page concatenated into one file for LLM use. Source: https://codeberg.org/groundshade/groundshade
---
# Getting started
> Drop GroundShade in behind Caddy, nginx, or HAProxy. Five minutes to a working request path.
GroundShade sits between your TLS terminator and your app. It does
not terminate TLS itself; your fronting proxy keeps doing that and
forwards the JA4 fingerprint as `X-JA4`.
```
client → TLS terminator (Caddy / nginx / HAProxy) → groundshade → your origin
```
## Run with Docker Compose
```yaml
services:
groundshade:
image: codeberg.org/groundshade/groundshade:latest
restart: unless-stopped
environment:
GROUNDSHADE_UPSTREAM_URL: http://your-app:3000
GROUNDSHADE_ADMIN_TOKEN: ${GROUNDSHADE_ADMIN_TOKEN:?set an admin token}
GROUNDSHADE_TRUSTED_PROXIES: 172.18.0.2/32
ports:
- "127.0.0.1:9090:9090"
volumes:
- groundshade-state:/var/lib/groundshade
volumes:
groundshade-state:
```
Generate the admin token once:
```sh
openssl rand -hex 32
```
Put it in your `.env`. The token gates `/admin/*` and `/metrics`.
Startup refuses to bind admin on a non-loopback address without a
token.
Then point your fronting proxy at `groundshade:8080` instead of your
app. Note that, if you are using docker, groundshade and your app need to share the same docker network.
## Check the dashboard
The container's admin port is published to host loopback on
`127.0.0.1:9090`. Open it in a browser:
```
http://127.0.0.1:9090/
```
The login page accepts your admin token and sets a cookie. After login
you land on the dashboard.
If you're not on the host, tunnel over SSH:
```sh
ssh -L 9090:127.0.0.1:9090 your-host
```
## Forward JA4 (optional, recommended)
The JA4 arm of the rate signal needs your fronting proxy to forward
the TLS fingerprint as `X-JA4`. Without it you keep the per-IP/24 arm
and trustless persistence; you lose detection of botnets that share a
TLS library across many IPs.
Stock Caddy has no JA4 placeholder. The build recipe lives at
[`examples/deploy/caddy-ja4.Dockerfile`](https://codeberg.org/groundshade/groundshade/src/branch/main/examples/deploy/caddy-ja4.Dockerfile)
and the matching Caddyfile at
[`examples/deploy/caddy-ja4.Caddyfile`](https://codeberg.org/groundshade/groundshade/src/branch/main/examples/deploy/caddy-ja4.Caddyfile).
For nginx and HAProxy see
[`nginx-ja4.conf`](https://codeberg.org/groundshade/groundshade/src/branch/main/examples/deploy/nginx-ja4.conf)
and
[`haproxy-ja4.cfg`](https://codeberg.org/groundshade/groundshade/src/branch/main/examples/deploy/haproxy-ja4.cfg).
GroundShade auto-detects JA4. After 100 requests (or 60 seconds), if
no JA4 has arrived, it logs a single WARN and the dashboard's signals
row reads `JA4: not detected`. Everything else keeps working.
## Trusted proxies
`GROUNDSHADE_TRUSTED_PROXIES` should list only your fronting proxy's
pinned IP. Trusted peers get `X-Forwarded-For` and `X-Real-IP` honored
and bypass the per-IP connection cap. Pin tight: a `/16` would grant
cap bypass to every container on the bridge.
Defaults trust only loopback (`127.0.0.1/32`, `::1/128`).
## Where to go next
- [Operating GroundShade](/docs/operating) covers daily workflows,
the dashboard, the metrics reference, and the tuning table.
- [Architecture](/docs/architecture) explains the request lifecycle
and the three defense levels.
- [Deployment hardening](/docs/hardening) is the pre-deploy security
checklist. Read it before publishing.
- [Behind Cloudflare](/docs/cloudflare) covers the orange-cloud
recipe and what gets silenced.
- [Building from source](/docs/build) builds local Docker images and
multi-arch releases.
- [FAQ](/docs/faq) lists the questions operators ask first.
---
# Operating GroundShade
> Daily operator guide. Configuration, defense levels, fast-lane allowlists, the admin dashboard, the metrics reference, and the tuning table.
This page covers running GroundShade in production. The wire formats
and full design live in
[SPEC.md](https://codeberg.org/groundshade/groundshade/src/branch/main/SPEC.md).
## TL;DR
```sh
# Plain run with built-in defaults. Forwards to http://127.0.0.1:3000.
groundshade
# With a config file:
groundshade --config /etc/groundshade/config.yaml
```
The proxy listens on `0.0.0.0:8080`. The admin port and dashboard
live on `127.0.0.1:9090`.
## Configuration
YAML, loaded in priority order:
1. `--config ` flag
2. `GROUNDSHADE_CONFIG` env var
3. `/etc/groundshade/config.yaml`
4. `./groundshade.yaml`
5. Built-in defaults (zero-config)
[`examples/config/full.yaml`](https://codeberg.org/groundshade/groundshade/src/branch/main/examples/config/full.yaml)
documents every option at its default.
[`examples/config/minimal.yaml`](https://codeberg.org/groundshade/groundshade/src/branch/main/examples/config/minimal.yaml)
is the smallest useful starting point.
### Environment overrides
These env vars override the loaded YAML at startup and on every
`SIGHUP` reload:
| Variable | Purpose |
|---|---|
| `GROUNDSHADE_CONFIG` | Config file path |
| `GROUNDSHADE_LISTEN_HTTP` | Inbound HTTP listener |
| `GROUNDSHADE_LISTEN_ADMIN` | Admin listener |
| `GROUNDSHADE_ADMIN_TOKEN` | Bearer token gating `/admin/*` and `/metrics` |
| `GROUNDSHADE_UPSTREAM_URL` | Default upstream URL |
| `GROUNDSHADE_TRUSTED_PROXIES` | Comma-separated CIDRs |
| `GROUNDSHADE_TRUST_STATE_DIR` | Trust-key state directory |
| `GROUNDSHADE_TRUST_SECRET` | HMAC signing key (hex) |
| `GROUNDSHADE_LOG_FORMAT` | `json` or `text` |
| `GROUNDSHADE_LOG_IP_HASH` | `true` hashes client IPs in logs |
The Docker image sets `GROUNDSHADE_LISTEN_ADMIN=0.0.0.0:9090` and
`GROUNDSHADE_TRUST_STATE_DIR=/var/lib/groundshade`. Outside Docker,
the state directory defaults to `$XDG_STATE_HOME/groundshade` or
`$HOME/.local/state/groundshade`.
### Hot reload
`SIGHUP` re-reads the config file and reapplies environment
overrides. The listeners drain gracefully and the proxy restarts on
the same PID with the same trust signing key, so outstanding
`gs_trust` cookies stay valid. Defense state, sliding windows,
connection counts, and metric counters all reset.
A validation failure logs a warning and keeps the old config
running. Bad reloads never take the proxy down.
To catch a bad config before you reload, validate it without starting
the server:
```sh
groundshade --check-config --config /etc/groundshade/config.yaml
# exit 0 + "config OK" if valid, exit 1 + the error if not
```
It applies the same env overrides as a real start, so it checks the
effective config. Run it as a container pre-flight
(`docker exec groundshade groundshade --check-config`) before the SIGHUP.
The inbound port is unbound briefly during reload (under 100 ms in
normal drains, up to 30 s if old connections take their time). For
true zero-downtime, run two replicas behind a load balancer.
In zero-config mode (no config file resolves), SIGHUP is logged and
ignored.
## What it does
Calm state: GroundShade behaves as a normal reverse proxy. No
challenge code runs.
Defense state: when a route's origin pain crosses thresholds (p95
latency or 5xx rate over a 30 s window with at least 50 samples),
the route lifts:
| Level | Who sees a challenge (without a trust token) |
|---|---|
| L1 | UAs matching `l1_ua_patterns`; forged-browser clients (UA claims browser, JA4 disagrees); optionally write methods if `l1_suspicion_methods` is set. v0.7.1's `always_challenge_forged_browser` flag promotes the forged-browser arm to fire at every level, including Open. |
| L2 | L1 scope plus thin clients (no `Referer`, no `Accept-Language`) |
| L3 | Everyone except the fast lane |
Defaults for `l1_ua_patterns`: `headless`, `bot`, `crawl`, `spider`,
`python`, `curl`, `go-http`, `libwww`. `l1_suspicion_methods` is
empty by default; opt in only for write-only APIs that never see a
real browser POST.
Independent of level, two behavioural signals can short-circuit:
- **Rate signal (hard threshold).** The 60 s sliding window per
`(route, IP /24)` or `(route, JA4)` crossed
`defense.rate_signals.hard_threshold`. Default: 1,000 requests.
- **Trustless persistence.** The `/24` was challenged
`defense.trustless_persistence.threshold` times without ever
solving. Default: 20. Sticks until a single solve clears it.
The rate signal's soft threshold acts as an extra L1 arm on the
firing request only. No permanent state change.
### Per-route policy knobs (v0.7.1)
Two config fields let you pin route policy without operator action:
- **`defense.escalation.min_level`** (default `open`): the level the
route is allowed to *fall to* on cooldown. Setting `l1` keeps a
sensitive route in active defense even when the detector reports
calm. The detector can still escalate above; it just won't step
below. Useful for admin panels, payment endpoints, and known scrape
targets. The difference from `shields_up` is that the detector still
drives the upper levels; `shields_up` pins all the way at
`shields_up`.
- **`defense.scope.always_challenge_forged_browser`** (default
`false`): when `true`, requests classified as ForgedBrowser
(browser-shaped UA paired with a script-tool JA4) are placed in
challenge scope at every level, including Open. Pairs with the v0.6
rate signal: rate catches volume; this flag catches polite low-rate
forgers that fly under the hard threshold. No-op under CF
orange-cloud, where every request arrives with CF's JA4 and the
classifier returns Browser for all.
Both are per-route overrides via the `routes` list, so the same proxy
can have a relaxed default route and a strict `/admin/*` route:
```yaml
routes:
- match:
path: "/admin/*"
defense:
escalation:
min_level: l1
scope:
always_challenge_forged_browser: true
```
### Fast lane
The fast lane always bypasses challenges. First match wins, in
order:
1. Client IP matches `fastlane.allow_ips` (CIDRs, not spoofable when
`trusted_proxies` is set correctly).
2. Path matches a configured feed glob (`/feed`, `/rss`, `*.atom`,
etc.).
3. `User-Agent` substring matches `fastlane.allow_user_agents`
(spoofable; pair with `allow_ips` if the threat model demands).
4. `Authorization: ApiKey id:secret` matches a configured key.
5. UA claims a known crawler (Google, Bing, DuckDuckGo, Apple,
optionally Yandex) **and** the IP passes reverse-DNS plus
forward-DNS verification.
`fastlane.crawlers.yandex` is `false` by default. The others are on.
### No-JS passage challenge (opt-in, v0.7.2)
The default HTML interstitial needs JavaScript to solve the
proof-of-work. Clients with JS off (Tor Browser on Safer/Safest,
NoScript users, text browsers, some accessibility setups) hit the
interstitial and have no way through. The passage challenge gives them
a path that does not need JavaScript.
It is a **friction layer, not a bot detector**. It raises cost on no-JS
clients (a one-click form, a server-enforced wait, single-use tokens,
ip-prefix + JA4 binding, a shorter trust TTL) but a patient client can
still pass. The JS proof-of-work stays the stronger proof; do not treat
the passage as a replacement.
Enable it per route with `challenge.mode`:
- `js` (default): JS PoW only; no-JS clients dead-end. No change.
- `auto`: JS clients solve the PoW as before; no-JS clients get the
passage form in the `