Skip to content
StudentOps

← All docs

Runbook

Runbook

Prerequisites (once)

First-time setup

Database

  1. In Supabase, copy the transaction pooler connection string for the postgres database.
  2. Set it as DATABASE_URL in a local .env and as a secret on Fly.io.
  3. From a local clone, run:
    python scripts/bootstrap_db.py
    This creates the four schemas and the meta.* plus raw.* 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

  1. Google Cloud Console, create an OAuth 2.0 client of type Desktop app.
  2. Add http://localhost:8765/oauth/callback to the redirect URIs.
  3. Add yourself as a test user on the consent screen (no public verification needed for personal use).
  4. Set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET in .env.
  5. Run:
    python -m studentops connect-google
    This opens a browser, you consent, and the refresh token lands in meta.oauth_tokens with provider = 'google_calendar'.

Resend (email digest)

  1. Add your domain in Resend.
  2. 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 duplicate v=spf1).
    • DKIM: CNAME resend._domainkey -> <project>.dkim.resend.com.
    • DMARC: TXT _dmarc "v=DMARC1; p=quarantine; rua=mailto:vikrant@vikrant69g.com".
  3. Wait for Resend to mark the domain verified (a few minutes).
  4. Set RESEND_API_KEY, RESEND_FROM=studentops@vikrant69g.com, RESEND_TO=<your inbox>.

Prefect Cloud

  1. Create a workspace in Prefect Cloud (free tier).
  2. Set PREFECT_API_URL and PREFECT_API_KEY in Fly.io secrets.
  3. Deploy schedules:
    python -m flows.deploy
    This registers the five cron schedules under the workspace.

Two-site deploy

Marketing site (studentops.vikrant69g.com)

  1. In Cloudflare Pages, Create project -> Connect to Git -> Vikrant892/studentops.
  2. Build settings:
    • Framework preset: Astro.
    • Build command: npm install && npm run build.
    • Build output directory: dist.
    • Root directory: marketing.
  3. Environment variables: none required. studentops.vikrant69g.com is served at the edge with no runtime secrets.
  4. Pages -> Custom domains -> add studentops.vikrant69g.com. Cloudflare wires the CNAME automatically because the zone is on the same account.
  5. (Cosmetic) Pages -> Settings -> Environment variables set PUBLIC_CF_BEACON_TOKEN to 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)

  1. From a local clone with flyctl authenticated:
    flyctl launch --no-deploy --config infra/fly.toml --copy-config
    # accept defaults: name studentops, region syd, no postgres (we use supabase), no redis
  2. 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
  3. First deploy:
    flyctl deploy --config infra/fly.toml
  4. Add the custom domain:
    flyctl certs add app.vikrant69g.com
    flyctl certs show app.vikrant69g.com
    It prints the CNAME target. Add it in Cloudflare DNS:
    • Type: CNAME, Name: app, Target: <your-app>.fly.dev, Proxy: on.
  5. In Cloudflare, SSL/TLS -> Overview must be Full (Strict). Anything else causes redirect loops with Fly.io’s HTTPS-only listener.
  6. (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

  1. Add an ORM model to src/studentops/common/raw.py with the canonical audit columns and a (_source, _natural_key) unique index.
  2. Write the ingestion module under src/studentops/ingestion/<name>.py. Mirror the shape of an existing one (httpx + retries, idempotent upsert, Result dataclass).
  3. Add the corresponding stg_<name>.sql view in dbt/models/staging/ and the matching .yml with not_null + unique on the natural key.
  4. Wire a task_<name> into flows/ingestion_flow.py.
  5. Extend _sources.yml with the new raw table.
  6. Update the synthetic generator if you want the demo to populate the table.

Common failure modes

SymptomLikely causeFix
App returns 502 the first time after idleFly.io auto-stop machine cold startWait 10s; consider raising min_machines_running to 1
Supabase project paused, DB unreachable7 days of inactivity on free tierUnpause from the Supabase dashboard; the daily flow keeps it warm
dbt build fails on snapshotdbt snapshot not run yetRun dbt snapshot once before dbt build
Citations suggester returns nothingLibrary empty or DOIs all unresolvedCheck raw.zotero_items row count and raw.citation_cache
Anthropic calls 429 inside the dayBudget gate (not Anthropic)Raise ANTHROPIC_DAILY_BUDGET_USD or wait until UTC midnight
OAuth refresh token expiredToken revoked in Google accountRe-run python -m studentops connect-google