documentation

behind cloudflare

Running GroundShade behind Cloudflare orange-cloud. Required trusted_proxies, what works, what gets silenced, and the Caddy recipe.

updated

Cloudflare in front of GroundShade is a supported topology since v0.7.0 (which fixed connection-cap saturation behind a fronting proxy). This page covers orange-cloud (CF terminating TLS). Grey-cloud is direct traffic and needs no special setup.

Orange-cloud works, but two security signals get silenced when CF terminates TLS at its edge. Confirm that trade-off matches your threat model before flipping it on.

Topology

internet → CF edge (TLS) → origin server
                            ├─ fronting Caddy (HTTP)
                            │      ├─ GroundShade
                            │      │      └─ your app
                            │      └─ (or directly to your app)

From GroundShade’s perspective:

  • Immediate TCP peer: your fronting Caddy.
  • X-Forwarded-For chain: <real-client>, <cf-edge-ip>. CF appends its edge IP.
  • TLS handshake (JA4): CF’s edge fingerprint. The real client’s handshake terminates at CF.

Required config

listen.trusted_proxies must list both your fronting Caddy and Cloudflare’s edge IP ranges. Without CF in the list, the XFF parser walks right-to-left and stops at the CF edge IP, treating it as the real client. Trust tokens, rate signals, and trustless persistence would then key on CF’s edges instead of the actual user.

listen:
  http: "0.0.0.0:8080"
  trusted_proxies:
    # 1. Your fronting Caddy (pinned container IP).
    - "172.18.0.10/32"

    # 2. CloudFlare IPv4 edge ranges.
    #    Source: https://www.cloudflare.com/ips-v4
    - "173.245.48.0/20"
    - "103.21.244.0/22"
    - "103.22.200.0/22"
    - "103.31.4.0/22"
    - "141.101.64.0/18"
    - "108.162.192.0/18"
    - "190.93.240.0/20"
    - "188.114.96.0/20"
    - "197.234.240.0/22"
    - "198.41.128.0/17"
    - "162.158.0.0/15"
    - "104.16.0.0/13"
    - "104.24.0.0/14"
    - "172.64.0.0/13"
    - "131.0.72.0/22"

    # 3. CloudFlare IPv6 edge ranges.
    #    Source: https://www.cloudflare.com/ips-v6
    - "2400:cb00::/32"
    - "2606:4700::/32"
    - "2803:f800::/32"
    - "2405:b500::/32"
    - "2405:8100::/32"
    - "2a06:98c0::/29"
    - "2c0f:f248::/32"

Verify the ranges before deploying. cloudflare.com/ips-v4 and cloudflare.com/ips-v6 are the source of truth; the ranges shift, so re-check before deploying.

Or set via env:

GROUNDSHADE_TRUSTED_PROXIES="$(curl -s https://www.cloudflare.com/ips-v4 | tr '\n' ',' | sed 's/,$//'),172.18.0.10/32"

Caddy config

Caddy needs its own trusted_proxies pointed at CF, otherwise it strips CF’s CF-Connecting-IP and X-Forwarded-For instead of forwarding them:

{
    servers {
        trusted_proxies static cloudflare
    }
}

your-site.example.com {
    reverse_proxy groundshade:8080 {
        # Preserve XFF chain so GroundShade can walk through CF to the
        # real client. Caddy appends its own IP to XFF here; the
        # GroundShade-side trusted_proxies handles the rest.
        header_up X-Forwarded-For {>X-Forwarded-For}, {remote_host}
    }
}

trusted_proxies static cloudflare is a Caddy module that auto-fetches and refreshes CF’s IPs. If you can’t use it, list the CIDRs explicitly: trusted_proxies static <CIDR> <CIDR> ....

What works behind orange-cloud

FeatureStatusNotes
ForwardingJust works
Connection-cap saturation✓ (v0.7+)CF edges don’t count against the per-IP cap
Real client IP in logsXFF chain resolves to the original client
Trust tokensBound to client /24 from XFF
Rate signal, per-IP armCounts by real client /24
Trustless persistencePer real client /24
Defense ladderDetector sees real latency / 5xx
Manual shields-upAlways works
Operator IP allowlistsResolved against real client IP
Operator UA allowlistsUA is forwarded unchanged

What doesn’t work (TLS termination limits)

FeatureStatusWhyWorkaround
JA4-keyed rate signal✗ silencedCF terminates TLS; origin sees CF’s JA4Per-IP/24 arm still works
Verified-crawler fast lane (rDNS)✗ deadrDNS runs on the peer IP (Caddy/CF), never resolves to googlebot.comUse fastlane.allow_user_agents
ForgedBrowser classifier✗ degradedUses JA4 ↔ UA consistency check; with uniform JA4, every request looks consistentRely on rate signal + trustless persistence + UA patterns

The dashboard will show JA4: detected because CF and Caddy are forwarding some JA4. It’s CF’s, not the real client’s. The per-JA4 counter in groundshade_signals_rate_tracked_keys{key="ja4"} sits at a small number relative to per-IP keys; that’s the visible fingerprint of the limit.

Because verified-crawler rDNS is dead behind CF, swap it for UA allowlists:

fastlane:
  crawlers:
    # rDNS verification can't see past CF; these flags do nothing here.
    google: true
    bing: true
    duckduckgo: true

  allow_user_agents:
    # Substring match, case-insensitive. Spoofable; the rate-signal
    # backstop catches mass abuse from anyone forging these.
    - "Googlebot"
    - "bingbot"
    - "DuckDuckBot"
    - "AppleBot"
    - "UptimeRobot"
    - "Uptime-Kuma"

If your threat model requires verified crawlers, don’t use orange-cloud. Grey-cloud preserves both JA4 and the rDNS fast lane.

Test the setup

  1. Real client IP visible. Hit your site from a known external IP. Check the log:

    docker logs groundshade | grep -E 'client_ip|method=' | head -3

    The client_ip (or client_ip_hash) should reflect your IP, not a 104.16.x.x / 172.64.x.x (CF edge) or 172.18.x.x (Caddy bridge) address.

  2. Per-IP rejection counter stays flat. Under normal load:

    curl -s -H "Authorization: Bearer $TOKEN" \
         http://127.0.0.1:9090/metrics \
      | grep 'connections_rejected_total{reason="per_ip"'

    Should sit at 0. If it climbs, your fronting Caddy’s IP is not in trusted_proxies.

  3. JA4 state acknowledges the limit.

    curl -s -H "Authorization: Bearer $TOKEN" \
         http://127.0.0.1:9090/admin/status \
      | jq .ja4

    Says "state": "detected" with few distinct JA4 keys tracked (about one per CF version). Expected.

  4. Trust tokens bind to real prefix. Solve a challenge from one IP, then send a second request from a different IP in the same /24 with the same cookie. It should pass (the token binds to /24). From a different /24: re-challenge expected.

Recommendation

Use grey-cloud (CF DNS-only) when you can. You get:

  • Real client JA4. Behavioural signals key on the actual TLS fingerprint.
  • rDNS verification. Known-good crawlers don’t see the wall.
  • One less authority in your trust chain.

Use orange-cloud when you need CF’s edge protections (WAF, edge rate limiting, L3/L4 DDoS, image optimisation) and accept losing JA4-keyed detection. GroundShade still provides per-IP rate, trustless persistence, and the trust-token wall, on a reduced signal set.