Ukraine War Live is an automated, serverless OSINT aggregator modelled on iranwarlive.com. It has no back-end server and no database in the usual sense. Everything runs on Cloudflare's edge platform, entirely within the free tiers, so the infrastructure itself costs $0 / month to run; the only real money is the domain and a Claude subscription (see section 11). This page describes the whole system in detail — enough to rebuild it.
The entire project — architecture, every line of code, the deployment, the DNS, and this write-up — was generated by Claude (Anthropic's Claude Code), with a human only steering, reviewing and approving. Source: github.com/4LaERz0GWNnuZqmnH/ukraine-war-live.
RSS feeds (13) DeepStateMap
│ │
▼ ▼
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ ┌──────────────┐
│ uwl-ingest-strikes │ │ uwl-ingest-ground │ │ uwl-ingest-frontline│ │ uwl-sitrep │
│ Worker + Cron 2h │ │ Worker + Cron 2h │ │ Worker + Cron 6h │ │ Cron 04:10 │
│ RSS → AI → dedup │ │ RSS → AI → dedup │ │ current + history │ │ 1 AI call/day│
└─────────┬──────────┘ └─────────┬──────────┘ └─────────┬──────────┘ └──────┬───────┘
└───────────────┬──────┴───────────────┬──────┴────────────────┬──┘
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ Cloudflare KV (one namespace) │
│ live:strikes live:ground archive:<pipeline>:<day> (+ index) │
│ dedup_store processed:<pipeline> promotions frontline:geojson │
│ sitrep:<date> stats:summary status:* runs_log │
└───────────────────────────────┬──────────────────────────────────┘
▼
┌──────────────────────────────────────────┐
│ uwl-api (Worker, edge-cached) │
│ api.ukraine.bugg.club │
│ /feed.json /api/search /archive/* │
│ /frontline.json /frontline/history/* │
│ /sitrep /rss.xml /status /llms.txt │
└─────────────────┬────────────────────────┘
▼
┌──────────────────────────────────────────┐
│ Cloudflare Pages — ukraine.bugg.club │
│ static HTML + self-hosted MapLibre GL │
└──────────────────────────────────────────┘Five Workers, one KV namespace, one Pages project. Cron Triggers do the scheduling; there is nothing to keep running and nothing to pay for at idle.
| Layer | Choice | Why |
|---|---|---|
| Compute | Cloudflare Workers | Free tier is 100k requests/day; Cron Triggers are included; no server to run or patch. |
| Schedule | Workers Cron Triggers | Cron expressions attached to a Worker. No external scheduler. |
| Extraction | Workers AI — Llama 3.3 70B (fp8, fast) | Runs on the same platform, billed in "neurons" with 10k/day free. No third-party API key. |
| State | Workers KV | Key/value at the edge. Free tier 100k reads & 1k writes/day; this system uses roughly 120 writes/day. |
| Archive | KV (NDJSON per day) | R2 and Google Sheets both need a one-time manual enable; KV needed neither and holds this volume easily. |
| Frontend | Cloudflare Pages | Free static hosting, automatic TLS, custom domains. |
| Map | MapLibre GL (self-hosted) + OpenFreeMap tiles | Both free and key-less. No Mapbox token, no tile bill. The library is served from the site itself, not a CDN. |
| Front line | DeepStateMap public API | Community OSINT map with a documented JSON endpoint, including a snapshot history. |
| Deploy | Wrangler 4 + GitHub Actions (Node 22) | One command per Worker; a push to main redeploys everything. |
Two ingest Workers share one code path (shared/pipeline.ts), parameterised by a PIPELINE variable of strikes or ground. Each fires on its own cron — strikes on the hour every 2 hours, ground 30 minutes later — so they never write KV at the same time.
Promise.allSettled. A feed that errors is recorded and skipped; it never fails the run. Cloudflare edge-caches each response for 5 minutes.title, link, publish date and a short summary from RSS and Atom items alike.processed:<pipeline> (30-day TTL, pruned each run). The set is per pipeline: most feeds are shared, so a single set let whichever worker ran first mark every article seen and starve the other. Up to 45 unseen items proceed.source_url of the item an event came from, and to never guess coordinates. Output is capped at 20 strike events / 12 ground events per run. If that model errors (retired, overloaded, quota) the run falls back to a smaller Llama in order; the model that actually answered is recorded in the run status.http(s) URL, a valid confidence tier, coordinates inside a Ukraine + western-Russia bounding box (else nulled). Anything malformed is discarded rather than shown.npm run build:gazetteer). Settlement-centroid precision, no geocoding API call.type + 0.1° grid cell + 1-hour bucket. A coarse position (gazetteer centroid or none) with a named place uses type + place slug + 3-hour bucket — so two outlets writing up the same strike on the same town collapse to one; with no place name it falls back to a normalised-headline hash. Territorial change and diplomacy use 4-hour buckets. If either signature is already in the KV key dedup_store the event is dropped. That store is pruned by age (30 days) and hard-capped at 40k keys.archive:<pipeline>:<day>, with a matching day → count index. Each pipeline only writes its own keys, so the append is race-free; the API merges the two on read.live:strikes / live:ground. That is what the map reads.status:* and a rolling runs_log.Every event carries exactly one tier, which is the marker colour on the map.
| Tier | Meaning |
|---|---|
| High confidence | Same event from 2+ independent outlets — in one run, or promoted when a later run corroborates it |
| Official — Ukraine | Attributed to Ukraine's General Staff, Air Force, or regional officials |
| Official — Russia | Attributed to the Russian Ministry of Defence |
| News wire | A single mainstream outlet, not attributed to either side |
| OSINT | Geolocated or analyst-sourced |
| State media | Sourced to TASS / RIA / Sputnik, unconfirmed (enforced in code by outlet name) |
uwl-ingest-frontline runs every 6 hours. It fetches deepstatemap.live/api/history/last — a ~600 KB GeoJSON of the current control map — and keeps only the polygons whose status is occupied, plus Crimea and the pre-2022 ORDLO areas. DeepState's satirical "territories" (Karelia, Ichkeria, and so on) and the liberated/dismissed polygons are filtered out. The trimmed FeatureCollection is stored at frontline:geojson and served, heavily cached, from /frontline.json; the map draws it as a semi-transparent red fill beneath the event markers. The same worker also keeps a weekly snapshot and, the first time it runs, backfills snapshots for ~7/14/30/60/90 days ago from DeepState's public history list — that is what the map's "compare front line to <date>" control draws as a blue underlay. Front-line data is © DeepStateMap.live, CC BY-NC-SA 4.0.
uwl-sitrep fires once a day at 04:10 UTC. It reads the previous UTC day's archived events, sends a slimmed copy to Llama 3.3 70B with an editor prompt, and stores the result — a one-line headline plus four to seven grouped bullets — at sitrep:<date>. Reports are kept indefinitely and shown on the Daily page, newest first. It is one small model call per day, a few hundred neurons.
One KV namespace holds everything:
| Key | Contents |
|---|---|
live:strikes, live:ground | Newest 200 events per pipeline (JSON array) — the map feed |
archive:<pipeline>:YYYY-MM-DD | Every kept event that day, NDJSON, uncapped |
archive:index:<pipeline> | { "2026-08-29": 41, ... } |
dedup_store, processed:<pipeline> | Signature / URL hash → timestamp; 30-day window, 40k-key cap. The processed set is per pipeline. |
promotions | Event id → corroboration count, so a promotion found by one pipeline reaches the other's feed (7-day TTL) |
frontline:geojson, frontline:snap:<date> | Current + historical occupied-territory FeatureCollections |
sitrep:<date>, sitrep:index | Daily digests |
stats:summary | Pre-aggregated 30-day page-view / visitor counts for the About page, rebuilt from Analytics Engine every 2 hours |
status:*, runs_log | Health / last-run diagnostics |
uwl-api is the fifth Worker, on api.ukraine.bugg.club. It has the KV binding and serves reads; idempotent GETs are cached at the Cloudflare edge (caches.default). Its one write path is a cron (every 2 hours) that folds the raw page-view data points from Cloudflare Analytics Engine into the stats:summary KV key the About page reads. The ingest Workers have no public URL at all — the API Worker invokes them for manual runs over a service binding (Worker-to-Worker, no internet hop).
| Endpoint | Returns |
|---|---|
GET /feed.json | Merged strikes + ground live events, newest first, with a meta block of real last-run times |
GET /api/events?type=&tier=&since= | Same, filtered |
GET /api/search?q=&from=&to= | Substring search over the archive |
GET /archive/index.json · /archive/YYYY-MM-DD | Day → count · that day's events as NDJSON |
GET /frontline.json | Current occupied-territory GeoJSON |
GET /frontline/history · /frontline/history/YYYY-MM-DD | Snapshot list · one past snapshot |
GET /sitrep · /sitrep/YYYY-MM-DD | Index + latest digest · one digest |
GET /rss.xml | Last 50 events as RSS 2.0 |
GET /status · /llms.txt | Pipeline health · machine-readable summary |
GET /stats | Pre-aggregated 30-day page-view / visitor counts (JSON) |
POST /hit | Page-view beacon — same-origin only, one Analytics Engine data point, 204 |
POST /admin/run?pipeline=&key= | Fire a pipeline now (key-gated) |
Plain HTML, one CSS file, one JS file — no framework and no application build. For deployment a small esbuild + html-minifier script (npm run build:web) minifies web/ into dist/ and rewrites every asset reference to a content hash, so a redeploy can never serve a stale mix of old and new files; Pages publishes dist/. MapLibre GL is served from the site itself (web/vendor/), not a CDN; the base map is OpenFreeMap's key-less "Liberty" style. The script plots markers coloured by confidence tier and offers filters by time window, event type and confidence tier, a time scrubber that plays events forward, the front-line compare control, and toggles for the occupied area and the independent-blog markers. The map is progressive enhancement: if WebGL or the tile server is unavailable, the sidebar — counts, per-type tally, and a list of events with no mapped location — still renders. Other pages: Daily (SITREPs), Chronology (with archive search), Status, and a chrome-less /embed.html for iframes. The feed refreshes every 10 minutes client-side; the data changes every 2 hours. A _headers file sets a Content-Security-Policy and the usual hardening headers. Every page except the embed loads a tiny fire-and-forget beacon (beacon.js) that POSTs only its path to /hit; the counting is done server-side against a daily-rotating hash, with no cookies and no client-side identifiers, and the 30-day totals are shown on the About page.
ukraine.bugg.club → Cloudflare Pages custom domain (CNAME to the *.pages.dev project).api.ukraine.bugg.club → a Workers custom domain on the uwl-api Worker (Cloudflare manages the record and cert)._redirects file 302s /feed.json and the other API paths from the site domain to the API Worker.| Component | Free allowance | This site's usage |
|---|---|---|
| Workers requests | 100,000 / day | a few thousand |
| Cron Triggers | included | ~41 invocations / day |
| Workers AI | 10,000 neurons / day | ~24 extraction calls + 1 SITREP / day |
| Analytics Engine (page-view stats) | free tier | ~1 write / page view, ~12 aggregation queries / day |
| KV writes | 1,000 / day | ~150 / day |
| KV reads | 100,000 / day | well under (edge-cached API) |
| Pages | unlimited static requests | — |
Domain (bugg.club) | — | $15 / year (~$1.25 / month) |
| Claude subscription — used to build & maintain | — | part of upkeep |
Everything on Cloudflare runs inside the free tiers, so infrastructure is $0. The only fixed cost is the $15/year domain (as of August 2026). A Claude subscription is the other part of the upkeep — it is what builds the site and reviews and ships every change.
Each Worker is a wrangler.toml plus a src/index.ts. Secrets (the manual-run key) are set once with wrangler secret put and survive redeploys. A GitHub Actions workflow (Node 22, wrangler 4) runs a type-check, lints the frontend JS (including every inline page script), and minifies web/ into dist/, then runs wrangler deploy for each of the five Workers and wrangler pages deploy dist for the site on every push to main. Cron schedules are declared in the Worker configs and registered automatically on deploy.
The whole project was generated by an AI agent from a running conversation, and it is meant to be reproducible the same way. Hand the repository to a capable coding agent along with the file REBUILD.md, which is written for exactly that: it lists the country-specific files to edit, the pipeline mechanics to leave alone, the Cloudflare API-token scopes, the one-off KV and secret setup, and the deploy and verify steps.
Giving an agent only this page gets a faithful re-architecture but not a faithful reproduction — the extraction prompts, the feed list, the event schema and the gazetteer are data the agent would otherwise have to re-invent, and the result drifts. The repository plus REBUILD.md is the artifact; everything needed is in it except your own account credentials.
REBUILD.md opens with a checklist the agent is told to put to you before it writes anything — because a different theatre changes: the RSS feed list, the geocoding gazetteer, the coordinate bounding box, the admin-region polygons, the control-map / front-line source (DeepStateMap is Ukraine-only), the actor framing in the prompts, and the site branding. If you want your own project for a different war, say so at the start and the agent will walk you through those choices; if you want the Ukraine site unchanged, you answer "same" and it skips them.