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