Appearance
Deployment
Who this is for: Platform operators and DevOps
What you'll achieve: Host SchoolRota in a UK-friendly environment with migrations and docs
Hosting guidance
Not for shared cPanel. Prefer:
- Fly.io / Railway / small UK VPS
- Azure if a trust requires it
Target region: UK-first (eu-west-2 or equivalent) for school data residency.
Application runtime
| Process | Purpose |
|---|---|
Next.js web (apps/web) | Tenant portals, operator UI, marketing, Stripe, tRPC, SSE |
Optional worker (apps/worker) | BullMQ background jobs when Redis is available |
Local default database is SQLite: Production staging on Fly uses SQLite on a volume (single machine). Postgres for scaled production: see repository docs/BILLING_AND_HOSTING.md.
Environment variables
Copy from .env.example and set production values:
| Variable | Notes |
|---|---|
DATABASE_URL | SQLite file or Postgres URL |
OPERATOR_API_TOKEN | Bootstrap only. Stops being accepted once a local admin and a named API token exist. See Operator SSO |
OPERATOR_BOOTSTRAP_OVERRIDE | 1 re-enables the bootstrap token for emergency recovery. Audited and alerted on every use |
OPERATOR_AUTH_DISABLED | 1 for local/CI only. Ignored when NODE_ENV=production |
OPERATOR_IP_ALLOWLIST | Optional. Comma-separated IPs or prefixes allowed to reach /operator. SCIM is exempt |
SECURITY_NOTIFY_EMAIL | Where operator security alerts are sent. Falls back to ADMIN_SIGNUP_NOTIFY_EMAIL |
OPERATOR_OIDC_ISSUER / CLIENT_ID / CLIENT_SECRET | Legacy. Imported into the console once on first read, then ignored. Configure SSO at Operator → Configuration instead |
OPERATOR_OIDC_GROUP_ROLE_MAP | Legacy. JSON IdP group → platform_admin | support | readonly |
OPERATOR_OIDC_ALLOWED_DOMAINS | Legacy. Optional email domain allowlist for operator SSO |
SESSION_SECRET | Long random string |
APP_URL | Public base URL |
EMAIL_FROM | From header |
EMAIL_PROVIDER | console or resend |
RESEND_API_KEY | When using Resend |
STRIPE_* | Self-serve signup (see BILLING_AND_HOSTING.md) |
REDIS_URL | Optional. Shared API rate limits across machines and BullMQ for apps/worker. Sessions stay in the DB. Calendar recovery still uses cron if Redis is down |
INTEGRATION_ENCRYPTION_KEY | Required for Outlook, Microsoft profile photos, Google Calendar, and both tenant and operator SSO client secrets: min 16 characters |
CRON_SECRET | Required for /api/cron/* endpoints (fail-closed without it) |
OPENAI_API_KEY | Full assistant chat, import mapping, and setup copilot |
AI_ASSISTANT_DAILY_TOKEN_CAP_TENANT | Fallback daily token cap when a tenant has no policy cap. New tenants are seeded with 80,000 |
Scheduled jobs (cron)
With CRON_SECRET set, schedule these endpoints with Authorization: Bearer {CRON_SECRET}:
| Endpoint | Suggested cadence | Purpose |
|---|---|---|
POST /api/cron/calendar-sync | Every 15 min (GitHub Actions) plus every 60s in the web process | Outlook and Google Calendar write-back |
POST /api/cron/entra-photos | Daily (GitHub Actions) | Refresh Microsoft profile photos for schools that enabled them |
GET /api/cron/morning-pack | Daily ~06:30 (school TZ) | Optional duty summary email |
GET /api/cron/trial-lifecycle | Every 15 minutes | Retry signup mail; send due trial reminders (including after expiry) and stalled-setup nudges |
GET /api/cron/billing-reconcile | Hourly | Repair incomplete Stripe conversion attempts and record settled invoices |
GET /api/cron/notification-digest | Every 15–30 min | Flush quiet-hours / digest email queue |
GET /api/cron/rota-document-email | Every 15 min (GitHub Actions) | Scheduled rota PDF/Word emails in each school's timezone |
GET /api/cron/missed-work-digest | Every 15 min (GitHub Actions) | Weekly missed-work report email in each school's timezone |
GET /api/cron/teaching-sync | Per MIS config | Teaching timetable pull |
Calendar write-back (Outlook & Google)
When enabling Outlook calendar or Google Calendar for pilot schools:
- Set
INTEGRATION_ENCRYPTION_KEYandCRON_SECRETin production secrets. - The web process drains the calendar outbox as soon as a job is created (publish, enable sync, resync) and again about every 60 seconds. GitHub Actions calls
POST /api/cron/calendar-syncevery 15 minutes as backup (CRON_SECRETmust match Fly). Redis +apps/workeris optional acceleration. - New schools already have
outlook_calendarandentra_profile_photoson. Enablegoogle_calendarin Schools & flags if needed, or turn Outlook off. - School admin completes the relevant integration setup in Admin.
Pending jobs do not need a manual curl. They run in the web process, then on the 60-second timer, then on the 15-minute GitHub Action.
Microsoft profile photos
When schools use Entra / Microsoft 365 photos:
- Set
INTEGRATION_ENCRYPTION_KEY(same key as Outlook). - School admin completes Admin → School → Microsoft profile photos (Entra tenant ID, client ID, secret, health check, then enable).
- GitHub Actions calls
POST /api/cron/entra-photosdaily as a refresh. Admins can also Refresh photos from the integration page. - The operator flag
entra_profile_photosis on unless explicitly turned off. Turning it off hides the Admin page and stops serving cached photos.
Fly.io quick start
fly volumes create schoolrota_data --region lhr --size 1fly secrets set OPERATOR_API_TOKEN=... OPERATOR_OIDC_ISSUER=... OPERATOR_OIDC_CLIENT_ID=... APP_URL=https://... SESSION_SECRET=...fly deploy- SSH: run migrations against
/data/schoolrota.sqlite - Create first tenant via
/operatoror CLI - Smoke-test login → setup → publish → export
Migrating from Dutylead (pre-rebrand)
If you already run the old dutylead Fly app:
- Fly app: create
fly apps create schoolrota(or rename the existing app) and updatefly.toml/ CI deploy target. - Volume / database: on the mounted volume, rename the SQLite file if needed:bashOr keep
flyctl ssh console -a schoolrota -C "mv /data/dutylead.sqlite /data/schoolrota.sqlite"DATABASE_URL=file:/data/dutylead.sqlitein Fly secrets until you cut over. - DNS: point
schoolrota.com,*.schoolrota.com, anddocs.schoolrota.comat Fly (or your host). - Secrets: set
APP_URL=https://schoolrota.com,EMAIL_FROM=SchoolRota <noreply@schoolrota.com>, and update Stripe webhook URL tohttps://schoolrota.com/api/billing/webhook. - IdP / SCIM: new schools should use
SchoolRota-*group names; legacyDutylead-*groups still map correctly. - Outlook: existing synced events keep working (legacy extension id supported); new syncs use
com.schoolrota.calendar.
Deploy checklist (generic)
- Set secrets and
APP_URL - Run migrations:
pnpm db:migrate - Seed only if creating the demo/bootstrap data is intentional
- Start web (and worker if Redis is configured)
- Provision the first real tenant via
/operatoror CLI - Smoke-test login → setup → build day → publish → export
- Confirm email delivery path is not
consolein production
Documentation site
This VitePress site builds to static files:
bash
pnpm docs:buildOutput: docs-site/.vitepress/dist
Deploy options:
- Live host: the Fly app serves the embedded VitePress dist from
apps/web/public/__docs. Guide URLs work onhttps://schoolrota.com/guide/...(for example Your first setup).docs.schoolrota.comshould be the same Fly origin (Cloudflare CNAME toschoolrota.fly.dev, plusfly certs add docs.schoolrota.com). If that hostname still serves GitHub Pages, new pages such as first-setup 404 even though Fly already has them. - Check: a Fly response includes
fly-request-idandx-powered-by: Next.js. GitHub Pages does not. - CI: the Docs workflow (
.github/workflows/docs.yml) only checks thatpnpm docs:buildsucceeds. A push that changesapps/**deploys Fly, which is what publishes the live docs. Do not excludedocs-sitefrom the Docker context.
Preview locally:
bash
pnpm docs:dev
# or after build:
pnpm docs:previewRelated
- Provisioning tenants
- Platform framework
- Local setup
- Engineering spec:
docs/SYSTEM_PLAN.mdin the repository - Reusable control plane:
docs/PLATFORM_FRAMEWORK.mdin the repository

