There is a new page on this site: /explore. It is a public, read-only view of what happens when a personal site runs a honeypot and a real edge network at the same time. Attack traffic against decoy endpoints, Cloudflare edge metrics, and legitimate human visitors - three independent vantage points on the same domain, refreshed on a minutes-scale cadence.
This post is the colophon for the stack underneath: which repos run which streams, why a Cloudflare Worker stitches them together, what gets refreshed when, and how the page rolls back when something breaks. A separate companion piece, What's Running at /explore, is the legend for the data itself - what every counter, code, and chart means.
What you will see
The page opens with four headline counters: events tracked, canary triggers, trap endpoints, and countries over the current window. The numbers move. They are not curated.
A reading-depth toggle in the header switches between Overview (headline widgets only) and Full depth (every chart described below).
Below the counters, four sections:
- The contrast. The composite view. Attacker volume next to sampled edge requests next to real pageviews. The contrast is the point, which is why the page leads with it.
- Attack traffic. Top attacked paths, canary trigger timeline, attackers by country, top hostile user agents, time-of-day patterns, and a sample of LLM-generated decoy responses. This is what the honeypot sees: requests to
/.env,/admin,/wp-login, and dozens of other trap endpoints that have no legitimate reason to be hit. - Edge. What Cloudflare handles before anything reaches the origin: HTTP volume, traffic by country, status codes, cache status, firewall blocks over time, top blocked countries and paths, and a recent-requests sample.
- Real visitors. Self-hosted Umami analytics. Real humans, measured without cookies, identifiers, or cross-site trackers.
What you will not see
No raw IP addresses. Source IPs are redacted to /24 for IPv4 and /48 for IPv6 inside the materialization query. The unredacted values never land in the database that serves the page.
No visitor tracking beyond pageviews and country. The Umami script does not set cookies, does not assign persistent visitor IDs, and does not share data with any third party.
No private data. The dashboard is read-only. Redacted events sit in a private schema, and owner-executed public views expose only the aggregate columns each tile needs. The browser's anonymous role can select those public views; it has no access to the private schema, ingestion table, or pipeline RPCs.
Why publish this
A site is mostly text. An infrastructure project produces signals. If the second one is running anyway, the signals should be visible. Two reasons specifically:
The first is honesty. Logs, counts, and traces are more credible than prose about logs, counts, and traces. The honeypot post that comes next will be easier to read with a live page sitting next to it.
The second is calibration. Most of the traffic to a personal site is not human. Showing the ratio - hostile, edge, legitimate, side by side - corrects the assumption that any of this is quiet. It is not.
The technical layer below covers what runs under the page.
Colophon
Three repositories, three independent data sources, one URL. The site is static HTML. The dashboard is a separate Next.js app on Vercel. A Cloudflare Worker stitches them together so the dashboard lives at jon.rehagen.net/explore instead of on its own subdomain.
The three repositories
| Repo | Role |
|---|---|
articles (private) |
Markdown drafts with YAML front matter. Build script converts to HTML and a GitHub Action opens a pull request against trellis. |
trellis |
Private. Static site, the honeypot capture stack, alerting, logging, Terraform, and the Cloudflare Worker. |
celbase |
Private. Next.js 16 App Router app served at /explore, Supabase migrations, and two Edge Functions that pull Cloudflare and Umami data on a schedule. The deployment URL is not published. |
Stack at a glance
| Layer | Component |
|---|---|
| Site origin | Cloud Run service serving static HTML |
| Edge | Cloudflare Pro: DNS, WAF, CDN, TLS termination, and one Worker (see below) |
| Google Workspace, MX records on Cloudflare DNS | |
| Site analytics | Self-hosted Umami behind Cloudflare Access, no cookies |
| Identity (private apps) | Cloudflare Access at the edge, Identity-Aware Proxy at Cloud Run |
| Secrets | Google Secret Manager, GitHub Secrets, Supabase Vault |
| CI/CD | GitHub Actions with Workload Identity Federation for GCP |
| Honeypot capture | Cloud Run service, Pub/Sub, BigQuery as source of truth |
| Dashboard data store | Supabase Postgres (rehagen.net project) |
| Dashboard app | Next.js 16 on Vercel, basePath: /explore |
| Apex routing | Cloudflare Worker proxying /explore[/*] to the Vercel-hosted app |
How /explore is wired
Browser
|
v
jon.rehagen.net/explore[/*] (Cloudflare zone)
|
| Workers Route binds the path to a single Worker
v
explore-rewrite (Cloudflare Worker, ~80 lines)
| Rewrites host to the Vercel app, preserves path/query/method/body.
| Strips hop-by-hop headers (RFC 7230 sec 6.1) and upstream Set-Cookie.
| Adds X-Forwarded-Host and X-Forwarded-Proto, plus Via for diagnostics.
v
(Vercel app)/explore[/*] (Next.js 16, basePath: /explore)
|
| SWR fetches from Supabase using the anon key
v
Supabase Postgres (project on rehagen.net)
- private.honeypot_events (rolling 30-day window, IP redacted before ingest)
- At publication: 21 owner-executed public views (7 honeypot_*, 11 cf_*, 3 umami_*)
- pg_cron invokes three Edge Function pollers on a minutes-scale cadence
Why a Worker, not a Page Rule, Transform Rule, or Cloud Run proxy
Each option got considered and rejected for a specific reason:
- Page Rule "Forwarding URL" issues a 301 or 302. The browser address bar would change to the Vercel host. Wrong UX.
- Transform Rule rewrites within the same hostname. It cannot cross-origin proxy. Wrong tool.
- Cloud Run proxy would put the static site in the request path for a dashboard that is otherwise independent. Couples two things that should fail separately.
- Worker runs at the edge, preserves the URL, is reversible in under thirty seconds by deleting the route binding, and lives in git. Cost is one extra hop at the edge, which is invisible compared to the cross-Atlantic Vercel fetch.
The worker is one file, around 110 lines including the comment header, with fifteen unit tests covering the path guard, header handling, and pass-through behavior. The route bindings are declared in wrangler.toml next to the code, so changes are auditable in pull request history.
The three data pipelines
Honeypot (BigQuery -> Supabase via Edge Function). The trellis stack publishes security events to Pub/Sub, which writes them into BigQuery. Every ten minutes, the honeypot-poll Edge Function queries a redacted 30-day projection from BigQuery and upserts it into private.honeypot_events. Moving the external query out of Postgres replaced an earlier FDW-backed materialized-view design that could wedge database workers when the remote query stalled.
Redaction happens in the BigQuery query before the Edge Function receives a row. Source IPs are truncated to /24 for IPv4 and /48 for IPv6, and a Postgres check constraint rejects values that do not match those forms. Unredacted values never land in Supabase storage.
Cloudflare (GraphQL Analytics API). A Supabase Edge Function (cf-poll) queries the Cloudflare GraphQL Analytics API on a minutes-scale cadence using a scoped token: Account > Analytics:Read and Zone > Analytics:Read on the rehagen.net zone only. The function writes into Cloudflare-prefixed tables that back the CF-1 through CF-10 visualizations.
Umami (REST API behind Cloudflare Access). A second Supabase Edge Function (umami-poll) hits the self-hosted Umami REST API on the same cadence. Auth is dual-layer. A Cloudflare Access service token gates the transport (CF-Access-Client-Id / CF-Access-Client-Secret). An Umami share token authorizes the read at the app layer. Either layer alone is insufficient.
Access control inside Postgres
The dashboard uses owner-executed views as a gatekeeper:
private.honeypot_eventsholds the redacted 30-day window. Onlypostgresandservice_rolecan access the private schema and table.- At publication, the public views comprised seven
honeypot_*aggregations, elevencf_*views, and threeumami_*views. They run with the view owner's permissions and explicitly project the aggregate columns the dashboard needs. The anonymous role receivesSELECTon those views only. - The Edge Function reads its BigQuery credential from Supabase Vault through service-role-only RPCs. The browser cannot call the ingestion RPCs or read the credential.
- A scheduled privilege audit checks the live grants rather than assuming migrations and production remain aligned.
What the page refreshes and how
Refresh cadence is set inside Postgres and the Edge Functions, not by the browser. The three Edge Function pollers each run on a minutes-scale schedule. The Next.js app reads from Supabase using SWR with revalidation on focus. The header surfaces a staleness indicator tied to the most recent ingested event so a stuck pipeline is obvious instead of silent.
Rollback
The Worker exists specifically because it is reversible. Two paths, both fast:
- Delete the two route bindings in the Cloudflare dashboard. Under thirty seconds. Traffic to
jon.rehagen.net/explorefalls back to whatever else is configured for the apex. - Re-comment the
[[routes]]blocks inwrangler.toml,wrangler deploy. Five to ten minutes. Git-tracked.
In both cases, the Vercel-hosted dashboard remains reachable directly as a fallback during incidents. That URL is not published.
What is next
Three planned additions:
- A
/colophonpage rendering the stack table as a permanent reference instead of a one-off post. - Bringing the Cloudflare zone (and this Worker's routes) under Terraform. Tracked in
trellis#31. - A long-form post on the honeypot itself: what it catches, the LLM decoy mechanics, and the canary patterns that work.
None of these change the core property of the site. Static HTML, no app server in the reader path for the writing pages, no third-party tracking, no surprise collection.