Static Export Pipeline
The public site (web/public-site/) reads its post listings and content
exclusively from R2 — there is no API call from Vercel back into the
worker for routine reads. The lib/posts.ts → fetchPostIndex helper fetches
${STATIC_URL}/posts/index.json (today
https://pub-1432fdefa18e47ad98f213a8a2bf14d5.r2.dev/static/posts/index.json,
see How the site finds the bucket)
and uses Next.js tag-based caching ({ tags: ['posts', 'post-index'] })
that the worker invalidates on publish.
This means R2 is the source of truth for the homepage and archive. A
publish that creates a posts row but never refreshes R2 silently freezes
the public site until the next successful export.
No bucket? With storage_provider=local (the fresh-install default) the
same export, and every other R2UploadService call, goes to a folder the worker
serves at /site/ with a small reader, instead of a bucket. The keys and file
layout below are identical. See
local-storage-provider.md.
Files on R2 (static/ prefix)
Write paths
publish_post_from_task (synchronous since 2026-05-11)
services/publish_service.py:publish_post_from_task is the single entry
point for materializing a published post. After the posts row INSERT
it now awaits export_post(_pool, slug) inline. The return value
lands on PublishResult.static_export_success so /approve and
/publish callers can surface the failure.
Originally (pre-2026-05-11) export_post was scheduled via
_spawn_background(...) as a fire-and-forget asyncio task. That worked
for the long-lived FastAPI worker but failed silently in two scenarios:
- Prefect subprocess auto-publish — the
content_generationflow runs in a subprocess that terminates when the flow returns. Background tasks scheduled mid-flow were cancelled before completing. - Worker restart mid-publish — any restart between INSERT and the asyncio scheduler picking up the fire-and-forget task dropped the work.
StaticExportReconciliationJob (every 15 min)
Safety net at services/jobs/static_export_reconciliation.py. Compares
COUNT(*) FROM posts WHERE status='published' against post_count in
static/manifest.json. On drift (count mismatch or manifest older than
the newest published_at by more than stale_minutes, default 30) it
fires export_full_rebuild and emits an r2_static_drift finding via
utils.findings.emit_finding.
Config keys (under plugin.job.static_export_reconciliation):
stale_minutes— manifest staleness threshold (default 30)r2_manifest_url— manifest probe URL (default points at the public R2 endpoint)alert_on_drift— emit a finding alongside the rebuild (default true)
plugins/registry.py next to
VerifyPublishedPostsJob; together they cover the two complementary
failure modes (R2 missing the post vs. the public URL returning non-200).
fire_post_distribution_hooks (gate clearance path)
When approval gates clear on a status='awaiting_gates' post,
publish_service.fire_post_distribution_hooks flips the status to
published and also calls export_post (still via
_spawn_background for now — separate gate-engine path with different
lifecycle, not in scope for the 2026-05-11 fix).
Credentials
Reads fromapp_settings (category general):
Legacy
cloudflare_r2_* keys still resolve as a fallback (see
services/r2_upload_service.py:_storage) but are deprecated.
How the site finds the bucket
The site names the bucket in one file,web/public-site/lib/static-url.js, and
every other file imports from it:
STATIC_URLisNEXT_PUBLIC_STATIC_URL, else the default (the Glad Labs bucket’sstatic/prefix). Every page, route handler, the sitemap, the RSS feed and the edge proxy (proxy.ts) fetch${STATIC_URL}/posts/index.jsonand its siblings from it.STATIC_ORIGINis that bucket’s origin: scheme and host, no path.next.config.jsallows it in the CSPconnect-srcand as anext/imageremote pattern, and the podcast and video feed routes fetch/podcast/feed.xmland/video/feed.xmlfrom it. The backend publishes both besidestatic/. WhenNEXT_PUBLIC_STATIC_URLis not an absolute http(s) URL it falls back to the default bucket’s origin.
storage_* settings above, and
storage_public_url must name the same origin as the site’s STATIC_ORIGIN.
Moving the bucket takes one edit on the site: change DEFAULT_STATIC_URL in
lib/static-url.js, or set NEXT_PUBLIC_STATIC_URL for the deploy (it is read
at build time, so redeploy after changing it on Vercel). The CSP allow-list and
the image pattern follow, because next.config.js reads the same module.
Nothing else on the site names the bucket, and
__tests__/static-url-single-source.test.js fails if something does.
That guard exists because the site used to hold a copy of the fallback in every
file that read the export, and next.config.js kept one for the CSP. A missed
copy sent one surface to the old bucket with no error, and when the CSP
allow-list and the fetch URL drifted apart, /search silently returned no
results (Gitea #262).
The bucket’s host is also spelled outside the site, in the few places that have
nowhere else to read it from: the DR re-import tool (it runs when the database
is gone, so its default is a literal on purpose), the operator’s settings
overlay, and two operator scripts. The brain’s R2 probe used to be one of them.
It now reads storage_public_url and reports not_configured when that is
unset, so an install no longer probes another operator’s bucket by default.
tests/unit/test_bucket_host_agreement.py compares every such spelling with the
site’s DEFAULT_STATIC_URL. A bucket move that misses one fails there, and the
failure lists each stale file:line, so there is nothing to search for by hand.
The DR tool’s default is held to the site’s exactly, path included.
Operator runbook
“R2 is stale, what do I do?”- Check the reconciliation watchdog’s last
job_runsentry — if it’s firing and the rebuild is failing, the metric instatic_export_reconciliationjob logs will showrebuild_ok=0. - Run a manual rebuild via the MCP
rebuild_static_exporttool or directly:docker exec poindexter-worker python -c "..."invokingservices.static_export_service.export_full_rebuild. - Trigger ISR revalidation so Vercel re-fetches:
await trigger_isr_revalidate(slug, site_config=sc).
PublishResult.static_export_success — true means R2 got the new
index. Pre-2026-05-11 publishes have no per-publish signal; cross-check
the manifest’s last_published_slug instead.