A San Francisco-focused news reader with a React frontend, an Express backend, a Postgres article store, and an automated LLM-powered news pipeline.
In production, the Docker/Coolify app serves both the public SPA and /api/* from the same Express process.
| Layer | Role |
|---|---|
src/ |
Public React UI: articles, events, search, reader, TTS controls |
server/ |
Express API, article pipeline, SEO HTML injection, Kokoro TTS |
| Postgres | Articles, pipeline runs, source attempts, events, newsletter subscribers, knowledge state |
| Docker/Coolify | Production runtime for the single Express + static app container |
- Node.js 22 for production parity with the Docker image
- Postgres connection string in
DATABASE_URL - Fireworks or Gemini API key
- Python/Kokoro dependencies are installed by the Docker image for production TTS
- Install dependencies:
npm ci- Create
.envfrom.env.exampleand set at least:
DATABASE_URL="postgresql://..."
LLM_PROVIDER=fireworks
FIREWORKS_API_KEY="..."
OWNER_CLI_SECRET="change-me-min-12-chars"- Run the backend:
npm run server:dev- Run the frontend in another terminal:
npm run devThe dev frontend calls http://localhost:8787 when VITE_API_URL is unset.
- Smoke-test the production server locally:
npm run build
NODE_ENV=production npm run server:startOpen http://localhost:8787.
| Command | Purpose |
|---|---|
npm run dev |
Vite dev server |
npm run server:dev |
Express dev server with tsx watch |
npm run server:start |
Production Express entry |
npm run build |
Build the Vite app into dist/ |
npm run lint |
Typecheck app and shared code |
npm run lint:server |
Typecheck server code |
npm run admin:articles |
Owner CLI for article edits |
Full examples live in .env.example.
| Variable | Notes |
|---|---|
DATABASE_URL |
Postgres URL, SSL recommended for hosted DBs |
LLM_PROVIDER |
fireworks or gemini |
FIREWORKS_API_KEY or GEMINI_API_KEY |
Required for article synthesis |
OWNER_CLI_SECRET |
Owner/admin secret for protected operational routes |
| Variable | Notes |
|---|---|
APP_URL |
Public site URL used for metadata, sitemap, and canonical links |
PORT |
Express port, default 8787 |
VITE_API_URL |
Leave unset for default Coolify single-container deploy |
CORS_ALLOWED_ORIGINS |
Only needed for split frontend/API deployments |
For the default Coolify deployment, do not set VITE_API_URL; the browser should use same-origin /api.
The app no longer depends on Inngest. Coolify/private-server production uses the internal timer by default:
INTERNAL_PIPELINE_INTERVAL_MS=600000
PIPELINE_COALESCE_MS=420000
DISABLE_INTERNAL_PIPELINE_CRON=falseBehavior:
- The first pipeline tick starts about 30 seconds after server boot.
- Later ticks run every
INTERNAL_PIPELINE_INTERVAL_MS. - A single-flight guard prevents overlapping pipeline runs.
PIPELINE_COALESCE_MSskips near-duplicate timer/external-cron attempts.- A tick attempts to publish; it does not guarantee a new article every time.
Optional external cron:
CRON_SECRET="change-me-long-random-secret"Then call:
GET /api/cron/pipeline?secret=...
Manual pipeline runs also accept CRON_SECRET or OWNER_CLI_SECRET.
Newsletter signup stores the email and selected categories in Postgres. Actual email delivery is disabled until a Postal mail server is configured:
POSTAL_API_URL="https://email.example.com"
POSTAL_API_KEY="postal-server-api-key"
NEWSLETTER_FROM="Times of SF <news@timesofsf.com>"
NEWSLETTER_REPLY_TO="hello@timesofsf.com"
NEWSLETTER_DIGEST_HOURS="8,20"
NEWSLETTER_TIMEZONE="America/Los_Angeles"
DISABLE_NEWSLETTER_SENDER=falsePOSTAL_API_URL/POSTAL_API_KEY come from a Postal mail server whose sending domain (matching NEWSLETTER_FROM) has been added and DNS-verified (SPF/DKIM/MX) in that Postal instance's admin UI.
Behavior: two digest emails a day (not a rolling per-article send), one per hour listed in NEWSLETTER_DIGEST_HOURS (local time in NEWSLETTER_TIMEZONE) — the earliest configured hour is the "morning" digest, any other is "night."
- Each digest targets 7-10 stories, picked by the LLM (same Fireworks model as article synthesis) for significance, each with a one-line "why it matters." Candidates are drawn from a rolling 30-day pool rather than "since the last digest," so a quiet news day still has enough to send — it's per-subscriber delivery tracking, not the window, that keeps morning and night from repeating.
- Tech and Funding are treated as priority beats: wherever they appear in the ranked pool, they're sorted above every other category, both by prompt instruction and by a deterministic sort afterward (not left to the LLM's discretion alone).
- Personalized to each subscriber's saved topic preferences (categories only apply here); a subscriber with no preferences gets the universal top stories across all categories, unfiltered.
newsletter_digest_runsmakes each day/slot idempotent — a digest is never sent twice, even if the check fires more than once in the same hour.newsletter_deliveriesrecords each subscriber/article pair so nothing already sent to someone is repeated in a later digest, no matter how far back it was.- Each email includes a tokenized unsubscribe link.
- The internal scheduler checks every 60s for a due hour.
POST /api/cron/newsletter(CRON_SECRETorOWNER_CLI_SECRET) checks the same way, or pass?slot=morning/?slot=nightto force a specific digest on demand (still respects the once-per-day guard).
TTS uses local Kokoro in production. The Docker image installs kokoro-onnx, espeak-ng, and downloads model files.
Recommended persistent cache on Coolify/private servers:
TTS_CACHE_DIR="/var/lib/timesofsf/tts-cache"Audio is stored as WAV:
- Chunk cache:
TTS_CACHE_DIR/<hash>.wav - Full-article cache:
TTS_CACHE_DIR/articles/<article-key>.wav
TTS routes have per-IP rate limits and size caps. The persistent worker has a timeout so a failed Python process cannot leave the queue stuck.
TTS_RATE is legacy from Edge TTS and is not used by the current Kokoro path.
Use one of these auth styles:
Authorization: Bearer <secret>x-admin-secret: <OWNER_CLI_SECRET>x-cron-secret: <CRON_SECRET>?secret=<secret>where supported
| Route | Auth |
|---|---|
POST /api/admin/article-from-url |
OWNER_CLI_SECRET |
POST /api/pipeline/run |
OWNER_CLI_SECRET or CRON_SECRET |
GET /api/cron/pipeline |
OWNER_CLI_SECRET or CRON_SECRET |
POST /api/cron/newsletter |
OWNER_CLI_SECRET or CRON_SECRET |
GET /api/runs/:runId |
OWNER_CLI_SECRET |
POST /api/posts/:id/expand |
OWNER_CLI_SECRET |
Public routes used by the frontend:
GET /api/postsGET /api/eventsPOST /api/newsletter/subscribewith{ email, preferences, source };preferencesaccepts one or more article categories such asTech,Civics,Funding,Culture,Transit,Food, orEvents.Politics/Politicalare saved asCivics.POST /api/tipswith{ postSlug?, kind, message, contactEmail? }— reader tips/corrections, rate-limited.POST /api/auth/signup,POST /api/auth/login,POST /api/auth/logout,GET /api/auth/me,PUT /api/auth/preferences— password-based accounts (tosf_sessionhttpOnly cookie), rate-limited.GET /api/auth/verify?token=,POST /api/auth/resend-verification— email ownership confirmation. Verification is not a gate: accounts work immediately and this only records that the address is real.POST /api/auth/forgot-password,POST /api/auth/reset-password— emailed reset links (1h expiry, single use). Reset tokens are stored as SHA-256 hashes, and a successful reset signs the user out everywhere.forgot-passwordalways returns the same response so it cannot be used to enumerate accounts.GET/POST/DELETE /api/bookmarks(/:postId)— requires a signed-in session.GET /health- TTS playback/download routes, rate-limited
The Dockerfile builds the frontend and starts Express with:
npm run server:startSet runtime environment variables in Coolify, not build-only variables:
DATABASE_URLLLM_PROVIDERFIREWORKS_API_KEYorGEMINI_API_KEYOWNER_CLI_SECRETAPP_URLTTS_CACHE_DIR- optional
CRON_SECRET
After deploy, check:
https://your-domain.com/health
Expected scheduler shape:
{
"internalPipelineCron": {
"active": true,
"intervalMs": 600000
}
}The site uses self-hosted PostHog for first-party analytics (pageviews, article views, sessions, referrers). It is loaded lazily from the browser with no npm dependency, so if PostHog is down or not configured, the site is completely unaffected.
PostHog is a large self-hosted stack (ClickHouse, Kafka, Postgres, Redis). The supported way to run it is the official hobby deploy — it provisions all backing services with one command on an Ubuntu VM (4 vCPU / 16 GB RAM recommended):
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/posthog/posthog/HEAD/bin/deploy-hobby)"Or install the PostHog Docker image (posthog/posthog:latest) in Coolify as a separate application
and set its runtime env vars per the PostHog self-host docs. Either way, you end up with:
- a public URL, e.g.
https://posthog.yourdomain.com - a project API key (
phc_...) from Settings → Project → API keys
Note: self-hosted PostHog is officially unsupported by PostHog and should run on the latest image. If you prefer zero infrastructure, PostHog Cloud's free tier (1M events/mo) is also compatible — just point
VITE_POSTHOG_HOSTathttps://us.i.posthog.com.
Set these as build variables in Coolify (the client bakes them in at build time):
VITE_POSTHOG_HOST="https://posthog.yourdomain.com"
VITE_POSTHOG_KEY="phc_..."Redeploy. The frontend now sends:
$pageviewon every route change (SPA navigation included)article_viewedwithslug,title, andcategorywhen an article is openedidentifywhen a user signs in or logs in (so PostHog can show signed-in traffic)
View dashboards in PostHog: Web analytics for pageviews/visitors, Events for
article_viewed, and Funnels to track signup → read paths.
Run before pushing:
npm run lint
npm run lint:server
npm run build
npm audit --audit-level=moderateReact 19, Vite 6, Tailwind 4, Express, Postgres, Fireworks/Gemini, Kokoro TTS, TypeScript.
Private project.