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/trial-lifecycleEvery 15 minutesRetry signup mail; send due trial reminders (including after expiry) and stalled-setup nudges
GET /api/cron/billing-reconcileHourlyRepair incomplete Stripe conversion attempts and record settled invoices
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:

  • Live host: the Fly app serves the embedded VitePress dist from apps/web/public/__docs. Guide URLs work on https://schoolrota.com/guide/... (for example Your first setup). docs.schoolrota.com should be the same Fly origin (Cloudflare CNAME to schoolrota.fly.dev, plus fly 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-id and x-powered-by: Next.js. GitHub Pages does not.
  • CI: the Docs workflow (.github/workflows/docs.yml) only checks that pnpm docs:build succeeds. A push that changes apps/** deploys Fly, which is what publishes the live docs. Do not exclude docs-site from the Docker context.

Preview locally:

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

SchoolRota documentation. Every slot covered, every day.