Skip to content

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 ​

ProcessPurpose
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:

VariableNotes
DATABASE_URLSQLite file or Postgres URL
OPERATOR_API_TOKENBootstrap only. Stops being accepted once a local admin and a named API token exist. See Operator SSO
OPERATOR_BOOTSTRAP_OVERRIDE1 re-enables the bootstrap token for emergency recovery. Audited and alerted on every use
OPERATOR_AUTH_DISABLED1 for local/CI only. Ignored when NODE_ENV=production
OPERATOR_IP_ALLOWLISTOptional. Comma-separated IPs or prefixes allowed to reach /operator. SCIM is exempt
SECURITY_NOTIFY_EMAILWhere operator security alerts are sent. Falls back to ADMIN_SIGNUP_NOTIFY_EMAIL
OPERATOR_OIDC_ISSUER / CLIENT_ID / CLIENT_SECRETLegacy. Imported into the console once on first read, then ignored. Configure SSO at Operator → Configuration instead
OPERATOR_OIDC_GROUP_ROLE_MAPLegacy. JSON IdP group → platform_admin | support | readonly
OPERATOR_OIDC_ALLOWED_DOMAINSLegacy. Optional email domain allowlist for operator SSO
SESSION_SECRETLong random string
APP_URLPublic base URL
EMAIL_FROMFrom header
EMAIL_PROVIDERconsole or resend
RESEND_API_KEYWhen using Resend
STRIPE_*Self-serve signup (see BILLING_AND_HOSTING.md)
REDIS_URLOptional. 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_KEYRequired for Outlook, Microsoft profile photos, Google Calendar, and both tenant and operator SSO client secrets: min 16 characters
CRON_SECRETRequired for /api/cron/* endpoints (fail-closed without it)
OPENAI_API_KEYFull assistant chat, import mapping, and setup copilot
AI_ASSISTANT_DAILY_TOKEN_CAP_TENANTFallback 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}:

EndpointSuggested cadencePurpose
POST /api/cron/calendar-syncEvery 15 min (GitHub Actions) plus every 60s in the web processOutlook and Google Calendar write-back
POST /api/cron/entra-photosDaily (GitHub Actions)Refresh Microsoft profile photos for schools that enabled them
GET /api/cron/morning-packDaily ~06:30 (school TZ)Optional duty summary email
GET /api/cron/notification-digestEvery 15–30 minFlush quiet-hours / digest email queue
GET /api/cron/rota-document-emailEvery 15 min (GitHub Actions)Scheduled rota PDF/Word emails in each school's timezone
GET /api/cron/missed-work-digestEvery 15 min (GitHub Actions)Weekly missed-work report email in each school's timezone
GET /api/cron/teaching-syncPer MIS configTeaching timetable pull

Calendar write-back (Outlook & Google) ​

When enabling Outlook calendar or Google Calendar for pilot schools:

  1. Set INTEGRATION_ENCRYPTION_KEY and CRON_SECRET in production secrets.
  2. 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-sync every 15 minutes as backup (CRON_SECRET must match Fly). Redis + apps/worker is optional acceleration.
  3. New schools already have outlook_calendar and entra_profile_photos on. Enable google_calendar in Schools & flags if needed, or turn Outlook off.
  4. 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:

  1. Set INTEGRATION_ENCRYPTION_KEY (same key as Outlook).
  2. School admin completes Admin → School → Microsoft profile photos (Entra tenant ID, client ID, secret, health check, then enable).
  3. GitHub Actions calls POST /api/cron/entra-photos daily as a refresh. Admins can also Refresh photos from the integration page.
  4. The operator flag entra_profile_photos is on unless explicitly turned off. Turning it off hides the Admin page and stops serving cached photos.

See Microsoft profile photos.

Fly.io quick start ​

  1. fly volumes create schoolrota_data --region lhr --size 1
  2. fly secrets set OPERATOR_API_TOKEN=... OPERATOR_OIDC_ISSUER=... OPERATOR_OIDC_CLIENT_ID=... APP_URL=https://... SESSION_SECRET=...
  3. fly deploy
  4. SSH: run migrations against /data/schoolrota.sqlite
  5. Create first tenant via /operator or CLI
  6. Smoke-test login → setup → publish → export

Migrating from Dutylead (pre-rebrand) ​

If you already run the old dutylead Fly app:

  1. Fly app: create fly apps create schoolrota (or rename the existing app) and update fly.toml / CI deploy target.
  2. Volume / database: on the mounted volume, rename the SQLite file if needed:
    bash
    flyctl ssh console -a schoolrota -C "mv /data/dutylead.sqlite /data/schoolrota.sqlite"
    Or keep DATABASE_URL=file:/data/dutylead.sqlite in Fly secrets until you cut over.
  3. DNS: point schoolrota.com, *.schoolrota.com, and docs.schoolrota.com at Fly (or your host).
  4. Secrets: set APP_URL=https://schoolrota.com, EMAIL_FROM=SchoolRota <noreply@schoolrota.com>, and update Stripe webhook URL to https://schoolrota.com/api/billing/webhook.
  5. IdP / SCIM: new schools should use SchoolRota-* group names; legacy Dutylead-* groups still map correctly.
  6. Outlook: existing synced events keep working (legacy extension id supported); new syncs use com.schoolrota.calendar.

Deploy checklist (generic) ​

  1. Set secrets and APP_URL
  2. Run migrations: pnpm db:migrate
  3. Seed only if creating the demo/bootstrap data is intentional
  4. Start web (and worker if Redis is configured)
  5. Provision the first real tenant via /operator or CLI
  6. Smoke-test login → setup → build day → publish → export
  7. Confirm email delivery path is not console in production

Documentation site ​

This VitePress site builds to static files:

bash
pnpm docs:build

Output: docs-site/.vitepress/dist

Deploy options:

  • GitHub Pages: see .github/workflows/docs.yml
  • Cloudflare Pages / Netlify: point at the same build command and output directory
  • Custom domain such as docs.schoolrota.com

Preview locally:

bash
pnpm docs:dev
# or after build:
pnpm docs:preview

SchoolRota documentation. Every slot covered, every day.