Runbook
Runbook
Prerequisites (once)
- Cloudflare account with
vikrant69g.comon it. - GitHub repo
Vikrant892/studentops(private until v1 is shipped error-free). - Fly.io account,
flyctlon PATH. - Supabase free-tier project, region Sydney.
- Optional: Resend account (for digests), Anthropic API key, Google Cloud OAuth client.
First-time setup
Database
- In Supabase, copy the transaction pooler connection string for the
postgresdatabase. - Set it as
DATABASE_URLin a local.envand as a secret on Fly.io. - From a local clone, run:
This creates the four schemas and thepython scripts/bootstrap_db.pymeta.*plusraw.*tables.
dbt
Copy dbt/profiles.yml.example to ~/.dbt/profiles.yml and fill in the
Supabase host, port, user, password, and dbname. Or set DB_HOST, DB_PORT,
DB_USER, DB_PASSWORD, DB_NAME env vars before invoking dbt.
cd dbt
dbt deps
dbt parse # validates models without touching the DB
dbt build # runs models + tests
dbt snapshot # SCD2 on assessments; safe to re-run
Google Calendar OAuth
- Google Cloud Console, create an OAuth 2.0 client of type Desktop app.
- Add
http://localhost:8765/oauth/callbackto the redirect URIs. - Add yourself as a test user on the consent screen (no public verification needed for personal use).
- Set
GOOGLE_OAUTH_CLIENT_IDandGOOGLE_OAUTH_CLIENT_SECRETin.env. - Run:
This opens a browser, you consent, and the refresh token lands inpython -m studentops connect-googlemeta.oauth_tokenswithprovider = 'google_calendar'.
Resend (email digest)
- Add your domain in Resend.
- Add the three DNS records Resend provides to Cloudflare:
- SPF:
TXT @ "v=spf1 include:_spf.resend.com ~all"(merge with any existing SPF record, do not duplicatev=spf1). - DKIM:
CNAME resend._domainkey -> <project>.dkim.resend.com. - DMARC:
TXT _dmarc "v=DMARC1; p=quarantine; rua=mailto:vikrant@vikrant69g.com".
- SPF:
- Wait for Resend to mark the domain verified (a few minutes).
- Set
RESEND_API_KEY,RESEND_FROM=studentops@vikrant69g.com,RESEND_TO=<your inbox>.
Prefect Cloud
- Create a workspace in Prefect Cloud (free tier).
- Set
PREFECT_API_URLandPREFECT_API_KEYin Fly.io secrets. - Deploy schedules:
This registers the five cron schedules under the workspace.python -m flows.deploy
Two-site deploy
Marketing site (studentops.vikrant69g.com)
- In Cloudflare Pages, Create project -> Connect to Git -> Vikrant892/studentops.
- Build settings:
- Framework preset: Astro.
- Build command:
npm install && npm run build. - Build output directory:
dist. - Root directory:
marketing.
- Environment variables: none required.
studentops.vikrant69g.comis served at the edge with no runtime secrets. - Pages -> Custom domains -> add
studentops.vikrant69g.com. Cloudflare wires the CNAME automatically because the zone is on the same account. - (Cosmetic) Pages -> Settings -> Environment variables set
PUBLIC_CF_BEACON_TOKENto the token from Analytics -> Web Analytics -> Manage site. The layout reads this at build time and only emits the beacon script when it’s set, so leaving it blank silently disables analytics without touching code.
App (app.vikrant69g.com)
- From a local clone with
flyctlauthenticated:flyctl launch --no-deploy --config infra/fly.toml --copy-config # accept defaults: name studentops, region syd, no postgres (we use supabase), no redis - Set Fly.io secrets:
flyctl secrets set \ DATABASE_URL='postgresql+psycopg://...supabase...' \ STREAMLIT_PASSWORD='<generate-something-strong>' \ ANTHROPIC_API_KEY=sk-... \ RESEND_API_KEY=re_... \ RESEND_TO=you@example.com \ PREFECT_API_URL=https://api.prefect.cloud/api/accounts/.../workspaces/... \ PREFECT_API_KEY=pnu_... \ ZOTERO_USER_ID=12345678 \ ZOTERO_API_KEY=... \ GOOGLE_OAUTH_CLIENT_ID=... \ GOOGLE_OAUTH_CLIENT_SECRET=... \ ICAL_FEEDS='https://timetable.example/foo.ics,https://deadlines.example/bar.ics' \ SYNTHETIC_MODE=false - First deploy:
flyctl deploy --config infra/fly.toml - Add the custom domain:
It prints the CNAME target. Add it in Cloudflare DNS:flyctl certs add app.vikrant69g.com flyctl certs show app.vikrant69g.com- Type: CNAME, Name: app, Target:
<your-app>.fly.dev, Proxy: on.
- Type: CNAME, Name: app, Target:
- In Cloudflare, SSL/TLS -> Overview must be Full (Strict). Anything else causes redirect loops with Fly.io’s HTTPS-only listener.
- (Optional) Cloudflare SSL/TLS -> Edge Certificates -> enable HSTS, one week max-age, no preload.
CI deploy
app-ci.yml automatically runs flyctl deploy on every push to main,
using the FLY_API_TOKEN repo secret. Generate the token with
flyctl auth token and add it under Settings -> Secrets and variables
-> Actions.
Common operations
Backfill a source from scratch
# 1. truncate the raw table
psql "$DATABASE_URL" -c 'TRUNCATE raw.zotero_items;'
# 2. delete the cached citation enrichment rows that pointed at it
psql "$DATABASE_URL" -c "DELETE FROM raw.citation_cache;"
# 3. re-ingest
python -m studentops pipeline --skip-ai
# 4. rebuild marts (idempotent)
cd dbt && dbt build
Rotate a Fly.io secret
flyctl secrets set RESEND_API_KEY=re_new_... --stage # stage only
flyctl deploy --config infra/fly.toml # apply
Roll back the app
flyctl releases --config infra/fly.toml # list releases
flyctl releases rollback v123 --config infra/fly.toml # back to vN
Roll back the marketing site
Cloudflare Pages keeps every successful build. Pages -> Deployments, pick an earlier one, Rollback to this deployment.
Add a new ingestion source
- Add an ORM model to
src/studentops/common/raw.pywith the canonical audit columns and a(_source, _natural_key)unique index. - Write the ingestion module under
src/studentops/ingestion/<name>.py. Mirror the shape of an existing one (httpx + retries, idempotent upsert,Resultdataclass). - Add the corresponding
stg_<name>.sqlview indbt/models/staging/and the matching.ymlwithnot_null+uniqueon the natural key. - Wire a
task_<name>intoflows/ingestion_flow.py. - Extend
_sources.ymlwith the new raw table. - Update the synthetic generator if you want the demo to populate the table.
Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| App returns 502 the first time after idle | Fly.io auto-stop machine cold start | Wait 10s; consider raising min_machines_running to 1 |
| Supabase project paused, DB unreachable | 7 days of inactivity on free tier | Unpause from the Supabase dashboard; the daily flow keeps it warm |
| dbt build fails on snapshot | dbt snapshot not run yet | Run dbt snapshot once before dbt build |
| Citations suggester returns nothing | Library empty or DOIs all unresolved | Check raw.zotero_items row count and raw.citation_cache |
| Anthropic calls 429 inside the day | Budget gate (not Anthropic) | Raise ANTHROPIC_DAILY_BUDGET_USD or wait until UTC midnight |
| OAuth refresh token expired | Token revoked in Google account | Re-run python -m studentops connect-google |