diff --git a/.env.example b/.env.example
index 8ec0b0c0..7da69879 100644
--- a/.env.example
+++ b/.env.example
@@ -26,7 +26,7 @@ POSTGRES_PASSWORD=postgres
# Auth secret for session signing. REQUIRED — the stack refuses to start if this is empty.
BETTER_AUTH_SECRET=
-# Dashboard-to-API auth key (leave blank — auto-generated by ./run-docker-full-stack.sh).
+# Dashboard-to-API auth key (leave blank — auto-generated by ./classroomio.sh).
# Must be the same value in both API and dashboard.
PRIVATE_SERVER_KEY=
@@ -82,10 +82,10 @@ SMTP_PASSWORD=
SMTP_SENDER=
# ──────────────────────────────────────────────
-# Optional — Object Storage (auto-configured by ./run-docker-full-stack.sh)
+# Optional — Object Storage (auto-configured by ./classroomio.sh)
# ──────────────────────────────────────────────
-# MinIO is included by default. On first run ./run-docker-full-stack.sh RANDOMIZES the
+# MinIO is included by default. On first run ./classroomio.sh RANDOMIZES the
# MinIO credentials below and mirrors them into OBJECT_STORAGE_ACCESS_KEY_ID/SECRET.
# These minioadmin placeholders are only used if you skip the script.
MINIO_ROOT_USER=minioadmin
diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md
deleted file mode 100644
index 16d1b887..00000000
--- a/FEATURE_AUDIT.md
+++ /dev/null
@@ -1,636 +0,0 @@
-# ClassroomIO — Feature & Flow Map (QA Audit)
-
-> **Purpose of this document:** a reverse‑engineered, citation‑backed map of every
-> feature in the ClassroomIO application, produced for QA to build test cases and
-> record demo videos. **Files are the source of truth.** Every claim is backed by a
-> file reference. Claims that could not be traced to code are marked
-> `⚠️ UNVERIFIED`. Ambiguities live in **§7 Open Questions**, not in the body.
->
-> Reference format: `` `path/to/file` `` → symbol/lines — what it proves.
-> Scope (per QA request): full depth on the user‑facing product
-> (`apps/dashboard`, `apps/api`, `apps/website`, `apps/embeds` + the packages that
-> back features); a lighter **Supporting Infrastructure** summary for the rest.
-> PRD folders (`prd/*`) are used only to cross‑check intent — code wins on conflict.
-
----
-
-## 1. System Overview
-
-**What it is.** ClassroomIO is an open‑source **Learning Management System (LMS) for
-companies** — compliance/employee training, customer education, and partner
-certification. It supports multi‑organization workspaces, courses/lessons/exercises,
-cohorts (cohorts), certificates, an AI course builder + in‑lesson tutor, a public
-REST API, an MCP server, and embeddable course widgets. It can be **self‑hosted** or
-run as the **cloud** product. (`README.md` → lines 11–50 — feature summary;
-`README.md` → 84–91 — monorepo apps.)
-
-**Tech stack** (cited from manifests):
-- **Monorepo**: pnpm + Turbo. `package.json` → root scripts/workspaces;
- `pnpm-workspace.yaml` → packages globs (`apps/*`, `packages/*`,
- `packages/course-app/src/*`); `turbo.json`.
-- **Frontend**: SvelteKit + Svelte 5 + TailwindCSS. `README.md` → 52–58 ("Built With");
- `apps/dashboard/svelte.config.js`.
-- **Backend API**: Hono (TypeScript). `apps/api/src/app.ts` → `new Hono()` app;
- `ARCHITECHTURE.md` → layered architecture (Routes → Services → Queries).
-- **Database**: PostgreSQL via Drizzle ORM. `packages/db/src/schema.ts` (~3,400 lines
- of `pgTable`/`pgEnum`); `packages/db/src/drizzle.ts`.
-- **Auth**: Better Auth (sessions, OAuth, SSO). `apps/api/src/app.ts` → 12, 107–188
- (`auth.handler`, `/api/auth/*`); `packages/db/src/auth.ts`.
-- **Background jobs**: BullMQ + Redis. `apps/jobs`, `apps/api/src/routes/admin/queues.ts`
- (`mountQueueDashboard`), `mediaJob`/`jobStep`/`deadLetterJob` tables in `schema.ts`.
-- **Object storage**: S3‑compatible / MinIO. `.env.example` → 77–89 (OBJECT_STORAGE_*);
- `README.md` → 176–191.
-- **Billing**: Polar. `apps/dashboard/src/routes/api/polar/*`.
-- **AI providers**: OpenAI / Google (Gemini) / Anthropic. `.env.example` → 110–115;
- `packages/utils/src/agent-models` (per `README.md` → 236).
-
-**High‑level architecture** (`ARCHITECHTURE.md` → 9–51):
-```
-SvelteKit dashboard (apps/dashboard) ──RPC (Hono client)──▶ Hono API (apps/api)
- │ server-side SSR proxy (.server.ts, PRIVATE_SERVER_KEY) │
- │ ▼
- Public org sites / embeds Services → Queries (Drizzle)
- (apps/website, apps/embeds) │
- ▼
- PostgreSQL · Redis/BullMQ · S3/MinIO
-```
-- Layered API: **Routes** (HTTP/validation/auth) → **Services** (business logic/
- transactions) → **Queries** (pure Drizzle). `ARCHITECHTURE.md` → 44–50.
-- Shared **Zod validation** in `packages/utils/src/validation/*`, used by both API and
- dashboard. `ARCHITECHTURE.md` → 45; `AGENTS.md` → 17–25.
-- Dashboard talks to the API through a typed Hono RPC client and an SSR auth proxy
- authenticated with `PRIVATE_SERVER_KEY`. `README.md` → 149–156.
-
----
-
-## 2. Feature Inventory
-
-Status legend: **Complete** = route+service+schema present; **Partial** = present but
-gated/limited or doc claims exceed code; **Stub/Planned** = PRD only, little/no code.
-
-| # | Feature | Purpose | Primary files | Status |
-|---|---------|---------|---------------|--------|
-| 1 | Authentication & sessions | Email/password, OAuth, session mgmt | `apps/api/src/app.ts:107`, `packages/db/src/auth.ts` | Complete |
-| 2 | Signup gating | Org‑scoped/invite‑only/self‑host bootstrap rules | `apps/api/src/middlewares/signup-guard.ts` | Complete |
-| 3 | Onboarding & org creation | Create profile + first organization | `apps/api/src/services/onboarding.ts`, `apps/dashboard/src/routes/(auth)/onboarding` | Complete |
-| 4 | Organizations (workspaces) | Multi‑org, settings, members, plan | `apps/api/src/routes/organization/organization.ts`, `schema.ts:1949` | Complete |
-| 5 | Team / people management | Invite teachers, assign roles | `apps/api/src/routes/course/people.ts`, `apps/dashboard/src/lib/features/people` | Complete |
-| 6 | Courses | Create/manage courses, sections, content | `apps/api/src/routes/course/course.ts`, `schema.ts:634` | Complete |
-| 7 | Lessons & lesson editor | Lesson content, multilingual, comments | `apps/api/src/routes/course/lesson.ts`, `.../lesson-language.ts`, `schema.ts:956` | Complete |
-| 8 | Exercises & questions | Quizzes, question types, sections, grading | `apps/api/src/routes/course/exercise.ts`, `.../submission.ts`, `.../mark.ts`, `packages/question-types` | Complete |
-| 9 | Course people / enrollment | Enroll students, roster | `apps/api/src/routes/course/people.ts` | Complete |
-| 10 | Course invites | Email + link invites with audit | `apps/api/src/routes/course/invite.ts`, `apps/api/src/services/course/invite.ts`, `schema.ts:1282` | Complete |
-| 11 | Org invites | Invite org members (EMAIL/LINK) | `apps/api/src/routes/invite/invite.ts`, `schema.ts:1384` | Complete |
-| 12 | Compliance training | Deadlines, renewals, grace, records | `apps/api/src/routes/course/compliance.ts`, `apps/api/src/routes/internal`, `schema.ts:768` (`courseCompletionRecord`) | Complete |
-| 13 | Certificates | Issue branded certificates | `packages/certificates/src/render.ts`, `schema.ts:850` (`courseCertificateIssue`) | Complete |
-| 14 | Cohorts | Group courses, goals, members, newsfeed | `apps/api/src/routes/cohort/cohort.ts`, `schema.ts` cohort tables | Complete |
-| 15 | Community Q&A | Per‑course questions/answers | `apps/api/src/routes/community/community.ts`, `schema.ts:1558` | Complete |
-| 16 | Course newsfeed | Course/cohort announcements + comments | `apps/api/src/routes/course/newsfeed.ts`, `schema.ts:1605` | Complete |
-| 17 | Attendance | Track group/live‑class attendance | `apps/api/src/routes/course/attendance.ts`, `schema.ts:321` (`groupAttendance`) | Complete |
-| 18 | AI Course Assistant | In‑course AI authoring/plan/edit chat | `apps/api/src/routes/agent/*`, `packages/ai-assistant`, `schema.ts:3163` (`aiAgentRun`) | Complete (key‑gated) |
-| 19 | AI Lesson Tutor | In‑lesson learner AI helper + fair‑use caps | `apps/api/src/routes/course/ai-tutor.ts`, `apps/api/src/routes/organization/ai-tutor.ts`, `schema.ts:3013` (`aiTutorMessageCount`) | Complete (key‑gated) |
-| 20 | AI credits / token billing | Token usage, balance, purchases | `schema.ts:2919` (`aiTokenUsage`), `apps/dashboard/src/lib/features/agent/api/credit-purchase.server.ts` | Complete (cloud billing) |
-| 21 | Course import | Import from prompt/PDF/course | `apps/api/src/routes/organization/course-import.ts`, `apps/api/src/services/course-import`, `schema.ts:2603` | Complete |
-| 22 | Media manager | Upload/manage video/docs/media, transcripts | `apps/api/src/routes/media/media.ts`, `.../transcripts.ts`, `.../hls.ts`, `apps/dashboard/src/lib/features/media`, `schema.ts:1028` (`asset`) | Complete |
-| 23 | Tags | Tag groups/tags/assignments for courses | `apps/api/src/routes/organization/tags.ts`, `schema.ts:2158` | Complete |
-| 24 | Org public site (org‑site) | Branded public course catalog/pages | `apps/api/src/routes/org-site`, `apps/dashboard/src/routes/(org-site)` | Complete |
-| 25 | Custom domains | Cloudflare custom domain mapping | `apps/api/src/routes/domain/domain.ts`, `apps/tenant-router` | Complete |
-| 26 | Course widget embed | Embeddable course catalog widget | `apps/api/src/routes/widgets`, `apps/dashboard/src/routes/(app)/widgets`, `apps/embeds`, `schema.ts:2255` (`widget`) | Complete |
-| 27 | Public REST API (v1) | API‑key automation: courses, audience | `apps/api/src/routes/v1/*`, `apps/api/src/services/v1`, `schema.ts:2539` (`organizationApiKey`) | Complete |
-| 28 | Automation / API keys | Issue mcp/api/zapier keys with scopes | `apps/api/src/routes/organization/automation.ts`, `apps/api/src/middlewares/automation-key*.ts` | Complete |
-| 29 | MCP server | `@classroomio/mcp` AI‑native course authoring | `packages/mcp/src/*` | Complete |
-| 30 | Enterprise SSO | OIDC SSO (Okta/Google Workspace/Auth0) | `apps/api/src/routes/organization/sso.ts`, `apps/api/src/routes/sso/discovery.ts`, `schema.ts:2479` (`organizationSsoConfig`) | Complete (licensed) |
-| 31 | Token auth | Token‑based auth policy | `apps/api/src/routes/organization/token-auth.ts`, `schema.ts:2513` (`organizationTokenAuth`) | Complete (licensed) |
-| 32 | Licensing | Self‑host license‑gated features | `apps/api/src/routes/license.ts`, `apps/api/src/middlewares/license.ts`, `packages/utils/src/license/*` | Complete |
-| 33 | Billing (Polar) | Subscriptions + AI token packs | `apps/dashboard/src/routes/api/polar/*` | Complete (cloud only) |
-| 34 | Analytics | Login/page events, org/course/country daily | `apps/api/src/routes/dash`, `apps/api/src/services/analytics`, `schema.ts:167–285` | Complete |
-| 35 | Email / notifications | Transactional emails via jobs | `apps/api/src/routes/mail`, `packages/email`, `apps/api/src/services/jobs` | Complete |
-| 36 | Background jobs | Media/email queues, BullMQ dashboard | `apps/jobs`, `apps/api/src/routes/jobs`, `apps/api/src/routes/admin/queues.ts` | Complete |
-| 37 | Unsplash image search | Image picker for content | `apps/api/src/routes/unsplash/unsplash.ts` | Complete |
-| 38 | Marketing website | classroomio.com landing | `apps/website` | Complete |
-| 39 | Documentation site | classroomio.com/docs | `apps/docs` | Complete |
-| 40 | Outbound webhooks | Emit `certificate.issued`, etc. | `prd/webhooks` (todo) | **Stub/Planned ⚠️** |
-| 41 | SCORM support | SCORM package support | `prd/scorm-support` (todo) | **Stub/Planned ⚠️** |
-| 42 | Notification system | In‑app notifications | `prd/notification-system` (todo) | **Stub/Planned ⚠️** |
-
-> Items 40–42 are listed in `PRD-TRACKER.md` → 9, 16, 24 as **todo**; treat as not yet
-> shipped. See **§7**.
-
----
-
-## 3. Per‑Feature Detail
-
-### 3.1 Authentication & Sessions
-- **Purpose:** authenticate users (email/password + OAuth) and attach org roles to the session.
-- **How it works:** All `/api/auth/*` requests are handled by Better Auth via
- `auth.handler`. `apps/api/src/app.ts` → 107–188. A global middleware resolves the
- session on every request and sets `user`, `session`, and `orgRoles` into context.
- `apps/api/src/app.ts` → 60–97. The app rebuilds the request host from
- `X-Forwarded-Host`/`-Proto` (Cloudflare proxy) and uses a Redis "oauth handoff" to
- shrink large OAuth callback cookies into a short token. `apps/api/src/app.ts` → 110–173.
-- **User flow:** Login page → submit email/password (or OAuth) → Better Auth sets
- session cookie → dashboard reads `/session`. `apps/dashboard/src/routes/(auth)/login`;
- `apps/api/src/app.ts` → 189–199 (`/session`).
-- **Key files:** `apps/api/src/app.ts`, `packages/db/src/auth.ts`,
- `packages/db/src/auth/hooks/create-profile.ts`.
-- **Edge cases:** OAuth cookie > 500 chars routed through Redis token (`app.ts:160`);
- `getSession` failures fall through as anonymous (`app.ts:71–84`); session carries
- `orgRoles` map used by RBAC middlewares (`app.ts:88`).
-
-### 3.2 Signup Gating (`signupGuard`)
-- **Purpose:** enforce org‑level and deployment‑level signup rules server‑side.
-- **How it works:** `apps/api/src/middlewares/signup-guard.ts` → 129–159. With a
- `cio-org-id` header: rejects when `org.disableSignup` (403 `SIGNUP_DISABLED`, line 148)
- or when `settings.signup.inviteOnly` and the email lacks an active invite (403
- `INVITE_REQUIRED`, lines 97–113). Without the header: cloud passes through; **self‑hosted
- requires org context** unless no org exists yet (bootstrap) or the email already belongs
- to the org. Lines 75–91 (`canProceedWithoutOrgContext`).
-- **User flow:** Signup form → POST `/api/auth/sign-up/*` (guarded, `app.ts:106`) → allowed
- / `SIGNUP_DISABLED` / `INVITE_REQUIRED` / `ORG_CONTEXT_REQUIRED`.
-- **Edge cases (tester probes):** invite‑only org without invite; disabled signup;
- self‑hosted with no `cio-org-id` and an existing org; unparseable body → 400 `INVALID_REQUEST`.
-
-### 3.3 Onboarding & Organization Creation
-- **Purpose:** create the user profile and first organization, then send a welcome email.
-- **How it works:** `apps/api/src/services/onboarding.ts`. `createOrganizationWithOwner`
- validates site‑name uniqueness (409 `SITENAME_EXISTS`, lines 36–40), creates org + admin
- member in a transaction (lines 44–61), and on **self‑hosted** assigns the org an
- `ENTERPRISE` plan with provider `selfhosted` (lines 64–77). **Self‑hosted blocks a second
- org** (403, lines 28–33). `completeOnboarding` enqueues a `welcome` email (lines 121–134).
-- **User flow:** Signup → `(auth)/onboarding` → enter name/org/site name → org+admin created → dashboard.
-- **Edge cases:** duplicate site name (409 + Postgres `23505` fallback, lines 93–95);
- second‑org attempt on self‑host; profile not found (404).
-
-### 3.4 Organizations (Workspaces)
-- **Purpose:** the top‑level tenant; owns courses, members, settings, plan, branding.
-- **How it works:** `apps/api/src/routes/organization/organization.ts` (CRUD + settings);
- `organization` table `schema.ts:1949` (siteName, settings, branding, `disableSignup`).
- Members live in `organizationmember` (`schema.ts:1754`) with `roleId` FK to `role`.
-- **User flow:** Org switcher → settings → update name/site/branding/signup policy.
- `apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-switcher.svelte`;
- `apps/dashboard/src/lib/features/settings`.
-- **Edge cases:** unique `siteName`; self‑hosted = single org (see §5); org settings drive
- signup gating (§3.2).
-
-### 3.5 Courses, Sections & Content
-- **Purpose:** author and deliver courses (self‑paced, live‑class, compliance, public).
-- **How it works:** course sub‑routers under `apps/api/src/routes/course/` — `course.ts`,
- `section.ts`, `content.ts`, `lesson.ts`, `exercise.ts`, `people.ts`, `newsfeed.ts`,
- `attendance.ts`, `compliance.ts`, `mark.ts`, `submission.ts`, `invite.ts`,
- `lesson-language.ts`, `payment-request.ts`, `presign.ts`, `ai-tutor.ts`, `katex.ts`
- (`index.ts` composes them). Course type enum `SELF_PACED|LIVE_CLASS|COMPLIANCE|PUBLIC`
- (`schema.ts:26`). Course row `schema.ts:634`.
-- **User flow:** Courses → New course → add sections/lessons → add exercises → publish →
- students enroll. `apps/dashboard/src/routes/(app)/courses/[id]`.
-- **Edge cases:** course type drives compliance/public behavior; locale enum (`schema.ts:27`,
- 10 languages) drives multilingual lessons.
-
-### 3.6 Lessons & Multilingual Editor
-- **Purpose:** lesson content with notes/slides/video, comments, translations.
-- **How it works:** `lesson` (`schema.ts:956`), `lessonComment` (`schema.ts:516`),
- `lessonCompletion` (`schema.ts:1490`), `lessonLanguage` + `…History` (`schema.ts:1706`,
- `1732`). Routes: `apps/api/src/routes/course/lesson.ts`, `.../lesson-language.ts`.
- PRDs `prd/lesson-edit [DONE]`, `prd/lessons-list [DONE]` confirm intent.
-- **User flow:** Open lesson → edit content → add translation → student marks complete.
-- **Edge cases:** language history retained; completion tracked per student.
-
-### 3.7 Exercises, Question Types & Grading
-- **Purpose:** quizzes/assessments with multiple question types and grading.
-- **How it works:** `exercise` (`schema.ts:1175`), `exerciseSection` (`schema.ts:1217`),
- `question`/`questionType`/`questionAnswer` (`schema.ts:1803–1913`), `submission`/
- `submissionstatus` (`schema.ts:571,617`), `exerciseTemplate` (`schema.ts:2401`).
- Routes `exercise.ts`, `submission.ts`, `mark.ts`. Shared types in `packages/question-types`.
- PRDs: `exercise-question-types`, `exercise-sections [DONE]`, `video-recording-question-type [DONE]`.
-- **User flow:** Author exercise → add questions (incl. video‑recording type) → student
- submits → tutor/admin marks → status recorded.
-- **Edge cases:** section‑based exercises; video‑recording answers (media pipeline);
- `prd/selective-exercise-assignment` is **todo** (assign specific exercises to specific
- students) — verify it is *not* yet available.
-
-### 3.8 Enrollment, Course Invites & Secure Invites
-- **Purpose:** add students to courses by email or shareable link, with audit trail.
-- **How it works:** `apps/api/src/services/course/invite.ts`; `courseInvite`
- (`schema.ts:1282`) + `courseInviteAudit` (`schema.ts:1342`); event‑type enum
- `schema.ts:53`. Org invites: `organizationInvite` (`schema.ts:1384`, EMAIL/LINK enum
- `schema.ts:71`) + `…Audit` (`schema.ts:1448`), routes `apps/api/src/routes/invite/invite.ts`.
- PRD `secure-student-invites [DONE]`.
-- **User flow:** Course → People → Invite (email or link) → invitee accepts via
- `apps/dashboard/src/routes/invite/[hash]` or `/invite/link/[hash]` → enrolled.
-- **Edge cases:** invite expiry/custom expiry (`AGENTS.md:231` references
- `customExpiresAt`); link vs email; audit events recorded; invite‑only signup interplay (§3.2).
-
-### 3.9 Compliance Training
-- **Purpose:** deadline tracking, renewals/retake intervals, grace periods, completion records.
-- **How it works:** `apps/api/src/routes/course/compliance.ts` and internal compliance
- router `apps/api/src/routes/internal` (`AGENTS.md:87–98`); `courseCompletionRecord`
- (`schema.ts:768`), `courseCompletionNotificationEvent` (`schema.ts:824`),
- `courseCertificateIssue` (`schema.ts:850`). Course type `COMPLIANCE` (`schema.ts:26`).
- PRD `compliance-training-platform [DONE]`.
-- **User flow:** Mark course as compliance → set deadlines/renewal → learners complete →
- completion record + certificate; renewal/grace events tracked.
-- **Edge cases:** grace periods, renewal intervals, waivers (per README 21), notification events.
-
-### 3.10 Certificates
-- **Purpose:** issue branded completion certificates with custom IDs.
-- **How it works:** `packages/certificates/src/render.ts` + `templates/` render the
- certificate; `courseCertificateIssue` (`schema.ts:850`) records issuance.
-- **User flow:** Learner completes course → certificate issued → downloadable/branded.
-- **Edge cases:** custom certificate IDs (README 21); template selection.
-
-### 3.11 Cohorts & Goals
-- **Purpose:** group courses into cohorts with goals, members, and progress tracking.
-- **How it works:** `apps/api/src/routes/cohort/cohort.ts`; tables `cohort`
- (`schema.ts:2646`), `programCourse` (`2677`), `programMember` (`2704`), `programNewsfeed`
- +`Comment` (`2739`,`2767`), `programGoal`/`…Assignment` (`2829`,`2878`) with goal‑type/
- deadline/status enums (`2803–2828`), status enum `ACTIVE|INACTIVE|ARCHIVED` (`2644`).
- Middlewares `cohort-member.ts`, `cohort-team-member.ts`. PRD `cohorts [DONE]`.
-- **User flow:** Create cohort → add courses + members → set goals/deadlines → track
- progress → cohort newsfeed. `apps/dashboard/src/routes/(app)/cohorts/[id]`.
-- **Edge cases:** goal deadline kinds; assignment status; archived cohorts.
-
-### 3.12 Community Q&A & Newsfeed
-- **Purpose:** per‑course discussion (questions/answers) and announcements.
-- **How it works:** `apps/api/src/routes/community/community.ts` with route‑specific
- `middlewares/question-author-or-team` (`ARCHITECHTURE.md:241–252`); tables
- `communityQuestion`/`communityAnswer` (`schema.ts:1558`,`1522`), `courseNewsfeed`/
- `…Comment` (`1605`,`1632`). Comment‑author middlewares `newsfeed-comment-author-or-team.ts`,
- `cohort-newsfeed-comment-author-or-team.ts`.
-- **User flow:** Course → Community → ask/answer; team can moderate. Newsfeed posts +
- comments at course and cohort level.
-- **Edge cases:** author‑or‑team authorization (only author or course team can edit/delete).
-
-### 3.13 AI Course Assistant (authoring chat)
-- **Purpose:** in‑course AI that generates outlines, lesson content, and edits.
-- **How it works:** `apps/api/src/routes/agent/{agent,history,runs}.ts`; `packages/ai-assistant`;
- run tracking in `aiAgentRun`/`…Step`/`…Event` (`schema.ts:3163`,`3225`,`3259`),
- conversation/model context/documents (`3083`,`3106`,`3138`). **Disabled until a provider
- key is set**; `GET /agent/status` flips to `enabled:true` when any of OPENAI/GOOGLE/
- ANTHROPIC key is present. `README.md:223–238`. Selected model persisted in `localStorage`
- (`classroomio-ai-chat-model`), provider resolved server‑side; missing key → 503
- `AI_NOT_CONFIGURED` (`README.md:236`).
-- **User flow:** Course → AI button (only shown when enabled) → chat to build/edit course.
-- **Edge cases:** no key → AI button hidden / 503; per‑model provider resolution; optional
- Tinybird observability (`TINYBIRD_TOKEN`).
-
-### 3.14 AI Lesson Tutor + Fair‑Use Caps
-- **Purpose:** in‑lesson AI helper for learners, with usage caps.
-- **How it works:** `apps/api/src/routes/course/ai-tutor.ts`,
- `apps/api/src/routes/organization/ai-tutor.ts`; `aiTutorMessageCount` (`schema.ts:3013`)
- + `aiTutorCapEvent` (`3050`). Settings UI `apps/dashboard/src/lib/features/ai-tutor-settings`.
- PRD `ai-tutor-fair-use`.
-- **User flow:** Learner in lesson → ask tutor → counts toward cap → cap event when exceeded.
-- **Edge cases:** per‑org cap configuration; cap‑exceeded behavior.
-
-### 3.15 AI Credits / Token Billing
-- **Purpose:** meter AI token usage and sell token packs.
-- **How it works:** `aiTokenUsage` (`schema.ts:2919`), `aiCreditBalance` (`2950`),
- `aiCreditPurchase` (`2974`); purchases recorded by Polar `order.paid` webhook →
- `CreditPurchaseApiServer.recordPurchase`. `apps/dashboard/src/routes/api/polar/webhook/+server.ts`
- → 33–82; token‑pack constants `@cio/utils/plans` (`TOKEN_PACK.TOKENS_PER_UNIT`, line 53).
-- **User flow:** Buy tokens (`/api/polar/buy-tokens`) → Polar checkout → webhook credits org balance.
-- **Edge cases:** **cloud only** (Polar); quantity/metadata parsing; UUID validation of
- `triggeredBy` (lines 61–66).
-
-### 3.16 Course Import
-- **Purpose:** create courses from a prompt, PDF, or existing course.
-- **How it works:** `apps/api/src/routes/organization/course-import.ts`,
- `apps/api/src/services/course-import`; `courseImportDraft` (`schema.ts:2603`) with source
- enum `prompt|pdf|course` (`schema.ts:29`) and draft status `DRAFT|PUBLISHED|ARCHIVED` (`30`).
-- **User flow:** Org → Import course → choose source → review draft → publish.
-- **Edge cases:** draft lifecycle; PDF parsing pipeline (jobs/media).
-
-### 3.17 Media Manager (video/docs, transcripts, HLS)
-- **Purpose:** upload and manage media; transcode video to HLS; generate transcripts.
-- **How it works:** routes `media/media.ts`, `transcripts.ts`, `hls.ts`, `course/presign.ts`
- (S3 presigned uploads). Tables `asset`/`assetUsage` (`schema.ts:1028`,`1086`),
- `mediaJob`/`jobStep`/`mediaTranscript`/`deadLetterJob` (`schema.ts:3297`,`3357`,`3393`,`3447`),
- `videoTranscripts` (`916`). Jobs processed by `apps/jobs`. Dashboard
- `apps/dashboard/src/lib/features/media`. PRD `media-manager [DONE]`. Storage via MinIO/S3
- (`.env.example:77–89`).
-- **User flow:** Media → upload → background transcode/transcribe → use asset in lesson.
-- **Edge cases:** job retries / dead‑letter; HLS cross‑origin headers (`app.ts:48–54`);
- Google Drive picker optional (`.env.example:99–101`).
-
-### 3.18 Tags
-- **Purpose:** categorize courses via tag groups/tags.
-- **How it works:** `apps/api/src/routes/organization/tags.ts`; `tagGroup`/`tag`/
- `tagAssignment` (`schema.ts:2158`,`2188`,`2225`). PRD `tags-feature [DONE]`.
-- **User flow:** Org → Tags → create groups/tags → assign to courses → filter catalog.
-
-### 3.19 Org Public Site (org‑site) & Theming
-- **Purpose:** branded public catalog and course landing pages for an org.
-- **How it works:** API `apps/api/src/routes/org-site` (mounted `/org-site/course`,
- `app.ts:217`); dashboard public routes `apps/dashboard/src/routes/(org-site)/{course,courses}`.
- Theme picker PRD `org-landing-page-theme-picker [DONE]`.
-- **User flow:** Visitor → org public site → browse courses → view course → enroll/sign up.
-- **Edge cases:** self‑host vs cloud routing differences (see §5); org branding/theme.
-
-### 3.20 Custom Domains
-- **Purpose:** map a customer's own domain to their org site.
-- **How it works:** `apps/api/src/routes/domain/domain.ts` (mounted `/domain`, `app.ts:203`);
- routing app `apps/tenant-router`. PRD `custom-domain-cloudflare [DONE]`.
-- **User flow:** Org settings → add custom domain → Cloudflare mapping → site served on domain.
-
-### 3.21 Course Widget Embed
-- **Purpose:** embed an org's course catalog on any external website.
-- **How it works:** API `apps/api/src/routes/widgets` (mounted `/widgets`, `app.ts:221`);
- dashboard `apps/dashboard/src/routes/(app)/widgets/[widgetId]` + `widget-preview`; build
- artifacts `apps/embeds` (`embeds:publish`, `README.md:256–257`). Tables `widget`/
- `widgetCourse`/`widgetVersion` (`schema.ts:2255`,`2305`,`2336`) with status/layout/
- selection enums (`42–52`). PRD `course-widget-embed [DONE]`.
-- **User flow:** Widgets → create widget → choose courses/layout → publish → copy embed
- snippet (`embed-preview.html`).
-- **Edge cases:** draft vs published widget; manual vs published course selection mode.
-
-### 3.22 Public REST API (v1) & Automation Keys
-- **Purpose:** programmatic access for integrations/automation.
-- **How it works:** `apps/api/src/routes/v1/{courses,audience}.ts` mounted at
- `/public-api/v1` (`app.ts:218`, `v1/index.ts`). Auth via `automationKeyMiddleware` +
- `automationKeyScopesMiddleware(['public_api:*'])` on every route; OpenAPI documented via
- `hono-openapi`. Courses endpoints: list/create/get/update/delete, list students, export,
- get/put structure (`v1/courses.ts`). Keys: `organizationApiKey` (`schema.ts:2539`, type
- `mcp|api|zapier`), usage tracked in `organizationAutomationUsage` (`2574`); managed via
- `apps/api/src/routes/organization/automation.ts`. PRD `public-api [DONE]`.
-- **User flow:** Org → API keys → create key with scopes → call `/public-api/v1/...` with key.
-- **Edge cases:** scope enforcement (403 on missing scope); usage categories `read|write|publish`
- (`schema.ts:32`); per‑org isolation via `orgId` from key context.
-
-### 3.23 MCP Server
-- **Purpose:** AI‑native course authoring through the Model Context Protocol.
-- **How it works:** `packages/mcp/src/{index.ts,api-client.ts,config.ts,tools/}`; published
- as `@classroomio/mcp` (`README.md:258`). Talks to the public API using an automation key.
- PRD `mcp-course-authoring [DONE]`.
-- **User flow:** Configure MCP client with API key → AI tools create/update courses.
-
-### 3.24 Enterprise SSO & Token Auth (licensed)
-- **Purpose:** OIDC SSO and token‑based auth for enterprise self‑host.
-- **How it works:** `apps/api/src/routes/organization/sso.ts`,
- `apps/api/src/routes/sso/discovery.ts` (mounted `/organization/sso`, `/sso`, `app.ts:211,213`);
- `organizationSsoConfig` (`schema.ts:2479`), provider enum `OKTA|GOOGLE_WORKSPACE|AUTH0`
- (`schema.ts:2444`), `organizationAuthPolicy` (`2447`). Token auth:
- `apps/api/src/routes/organization/token-auth.ts`, `organizationTokenAuth` (`2513`).
- **License‑gated on self‑host** via `requireLicense('sso'|'token-auth')`
- (`apps/api/src/middlewares/license.ts`). PRD `enterprise-sso [DONE]`.
-- **User flow:** Org → SSO settings → register IdP (discovery fetches
- `.well-known/openid-configuration`) → users sign in via IdP.
-- **Edge cases:** trusted OIDC origins allow‑listed (`packages/db/src/constants.ts:14–24`);
- cloud has no license restriction (see §5).
-
-### 3.25 Licensing
-- **Purpose:** gate Enterprise self‑host features behind a license key.
-- **How it works:** `apps/api/src/routes/license.ts` (mounted `/license`, `app.ts:209`),
- `apps/api/src/services/license.ts` (`isFeatureLicensed`), feature ids `sso`,
- `token-auth`, `no-tracking` (`packages/utils/src/license/constants.ts:8–10`),
- plan→feature mapping `packages/utils/src/license/plan-features.ts`. Dashboard side
- `apps/dashboard/src/lib/features/license/api/license.svelte.ts` (`licenseApi.hasAccess`).
-- **User flow:** Set `LICENSE_KEY` (`.env.example:117–118`) → licensed features unlock.
-- **Edge cases:** `requireLicense` is a **no‑op on cloud** (`license.ts:15–19`).
-
-### 3.26 Billing (Polar) — cloud
-- **Purpose:** paid subscriptions and AI token packs.
-- **How it works:** `apps/dashboard/src/routes/api/polar/{subscribe,buy-tokens,portal,webhook}`.
- Webhook handles `order.paid` (token packs → credit balance) and `subscription.*`
- (create/update/cancel org plan). `webhook/+server.ts` → 30–168. Plans enum
- `EARLY_ADOPTER|ENTERPRISE|BASIC` (`schema.ts:28`), org plan stored in `organizationPlan`
- (`schema.ts:1667`).
-- **User flow:** Upgrade → Polar checkout → webhook activates/updates plan; manage via portal.
-- **Edge cases:** **cloud only**; subscription cancel → `cancelOrgPlan`; webhook secret
- `POLAR_WEBHOOK_SECRET` required.
-
-### 3.27 Analytics
-- **Purpose:** usage analytics for orgs/courses.
-- **How it works:** `apps/api/src/routes/dash` + `apps/api/src/services/analytics`; tables
- `analyticsLoginEvents`, `analyticsPageEvent`, `analyticsOrgDaily`, `analyticsCourseDaily`,
- `analyticsCountryDaily` (`schema.ts:167–285`). Dashboard `apps/dashboard/src/lib/features/analytics`.
- Client tracking (PostHog/Umami) gated by deployment mode / license (§5).
-- **User flow:** Org → Analytics dashboard → view login/page/course metrics.
-
-### 3.28 Email / Notifications & Background Jobs
-- **Purpose:** transactional email and async processing.
-- **How it works:** `apps/api/src/routes/mail`, templates in `packages/email`,
- enqueued via `enqueueTransactionalEmail` (`apps/api/src/services/jobs`,
- `onboarding.ts:128`). Workers in `apps/jobs`; BullMQ dev dashboard at `/admin/queues`
- (`apps/api/src/routes/admin/queues.ts`, `app.ts:232–234`). Email providers: Zoho/ZeptoMail
- or SMTP (`.env.example:62–74`).
-- **User flow:** trigger (signup, invite, completion) → email queued → worker sends.
-- **Edge cases:** idempotency keys (`onboarding.ts:133`); dead‑letter jobs (`schema.ts:3447`).
-- **Note:** an in‑app **notification system** is `prd/notification-system` **todo** — not shipped.
-
----
-
-## 4. Roles & Responsibilities
-
-**Org roles** are integers: `ADMIN=1`, `TUTOR=2`, `STUDENT=3`
-(`packages/utils/src/constants/roles.ts:1–4`). They are stored on
-`organizationmember.roleId` (`schema.ts:1767`, FK to `role` table `schema.ts:1913`) and
-surfaced into the session as an `orgRoles` map keyed by org id (`apps/api/src/app.ts:88`).
-RBAC middlewares read `orgRoles[orgId]` (org id from the `cio-org-id` header) — **no
-per‑request DB lookup** (`apps/api/src/middlewares/org-team-member.ts:5–53`).
-
-| Role | Can do | Cannot do | Enforced by |
-|------|--------|-----------|-------------|
-| **ADMIN** (1) | Full org control: settings, members, billing, courses, SSO, API keys, all team actions | — | `org-admin.ts`, `org-team-member.ts` (admin∈team) |
-| **TUTOR** (2) | Team actions: author/manage courses, grade, moderate community/newsfeed | Org admin‑only ops (org settings, billing, member roles) | `org-team-member.ts:44` (ADMIN or TUTOR), `course-team-member.ts` |
-| **STUDENT** (3) | Enroll, learn, submit exercises, post community Q&A, use tutor | Authoring/grading/team/admin actions | absence of team/admin role → 403 |
-| **Guest / anonymous** | Public org sites, public courses, embeds, public API (with key) | Any authed action | session = null path (`app.ts:77–84`); public CORS paths (`app.ts:60–67`) |
-
-**Resource‑scoped middlewares** (`apps/api/src/middlewares/`):
-- `authMiddleware` — requires a session (`auth.ts`).
-- `orgMemberMiddleware` — must be org member; `orgTeamMemberMiddleware` — ADMIN|TUTOR;
- `orgAdminMiddleware` — ADMIN only (`ARCHITECHTURE.md:233–237`).
-- `courseMemberMiddleware` / `courseTeamMemberMiddleware` — course‑level membership/team.
-- `cohortMemberMiddleware` / `cohortTeamMemberMiddleware` — cohort-level.
-- `apiKeyMiddleware` / `automationKeyMiddleware` (+ `automationKeyScopesMiddleware`) —
- machine auth for webhooks/public API; `authOrApiKeyMiddleware`,
- `authOrAutomationKeyMiddleware`, and `*-or-automation-key`/`*-or-api-key` variants allow
- either a session or a key.
-- `requireLicense(feature)` — self‑host license gate (`license.ts`).
-- `workspaceNotReadonlyMiddleware` — blocks writes on read‑only workspaces
- (`workspace-not-readonly.ts`).
-- `signupGuard` — signup rules (§3.2). `rateLimiter` — global rate limit (`app.ts:98`).
-- Route‑specific author/team guards: `question-author-or-team`,
- `newsfeed-comment-author-or-team`, `cohort-newsfeed-comment-author-or-team`.
-
-**Public API scopes:** automation keys carry scopes like `public_api:*`, enforced per route
-(`apps/api/src/routes/v1/courses.ts:47`); usage categorized `read|write|publish`
-(`schema.ts:32`).
-
----
-
-## 5. Deployment Modes — Self‑Hosted vs Cloud
-
-The single switch is **`PUBLIC_IS_SELFHOSTED`** (`=true` ⇒ self‑hosted). Source of truth:
-`.env.example:26` (Docker stack), `apps/dashboard/.env.example`, and the local‑dev value
-`apps/dashboard/.env` (`PUBLIC_IS_SELFHOSTED=true`, `README.md:155`). The flag is read in
-both the API/db layer (`process.env.PUBLIC_IS_SELFHOSTED`) and the dashboard
-(`$env/static/public`). Every behavioral difference below is traced to a flag check.
-
-### 5.1 Self‑Hosted mode (`PUBLIC_IS_SELFHOSTED=true`)
-- **Setup:** Docker Compose full stack — `cp .env.example .env` then
- `./run-docker-full-stack.sh` (auto‑generates `PRIVATE_SERVER_KEY`, `BETTER_AUTH_SECRET`);
- `README.md:240–249`, `docker-compose.yaml`, `docker/Dockerfile.{api,dashboard}`,
- `docker/docs/SELF_HOST.md`. Bundled Postgres, Redis, MinIO.
-- **Single organization:** org creation **blocks a second org**
- (`apps/api/src/services/onboarding.ts:28–33`); the new org is auto‑assigned an
- `ENTERPRISE` plan with provider `selfhosted` (`onboarding.ts:64–77`). The "add org" UI is
- **hidden** (`apps/dashboard/src/routes/(app)/org/[slug]/+layout.svelte:29–31`) and the
- org‑site→LMS redirect is **skipped** (`.../+layout.server.ts:16`). PRD `self-host-single-org.md`.
-- **Signup:** requires org context unless bootstrapping the first org or the email already
- belongs to the org (`signup-guard.ts:75–91`).
-- **Licensing:** Enterprise features (`sso`, `token-auth`, `no-tracking`) are **gated by
- `LICENSE_KEY`** — `requireLicense` actively checks (`middlewares/license.ts:15–22`);
- `.env.example:117–118`.
-- **Tracking:** cloud analytics (PostHog/Umami) are **disabled**
- (`apps/dashboard/src/lib/utils/functions/appSetup.ts:23–27`); a `no-tracking` license also
- suppresses tracking (`appSetup.ts:29–37`).
-- **URLs/links:** `BASE_URL` and invite/email links use `DASHBOARD_ORIGIN` when self‑hosted
- (`packages/db/src/constants.ts:36–37`; `.env.example:32–35`). Dashboard SSR calls the API
- internally via `PRIVATE_SERVER_URL` (`.env.example:28–30`).
-- **Storage/email:** MinIO + Zoho/SMTP from `.env` (`.env.example:62–89`).
-
-### 5.2 Cloud mode (`PUBLIC_IS_SELFHOSTED` ≠ `true`)
-- **Multi‑org:** users can create/switch multiple orgs (no single‑org block); "add org" UI
- shown (`+layout.svelte:29`).
-- **Billing:** Polar subscriptions + AI token packs are active
- (`apps/dashboard/src/routes/api/polar/*`); plan set via subscription webhooks
- (`webhook/+server.ts`). License gate is a **no‑op** — cloud has no license restrictions
- (`middlewares/license.ts:15–19`).
-- **Tracking:** PostHog/Umami/UserJot analytics enabled (`appSetup.ts:17–27`).
-- **Signup:** org context optional unless org settings restrict it (`signup-guard.ts:132–139`).
-- **Infra:** hosted Postgres/Redis/S3; dashboard deployed on Cloudflare Workers
- (`apps/dashboard/wrangler.jsonc`, `svelte.config.js`).
-
-### 5.3 Mode‑exclusive features (summary)
-| Concern | Self‑Hosted | Cloud |
-|---|---|---|
-| Number of orgs | One only (`onboarding.ts:28`) | Many |
-| Enterprise SSO / token‑auth | Requires `LICENSE_KEY` (`license.ts`) | Always allowed (no license) |
-| Billing (Polar) | Not used (plan = selfhosted ENTERPRISE) | Subscriptions + token packs |
-| Analytics tracking | Off (unless… still off) (`appSetup.ts:24`) | On |
-| API base / links | `DASHBOARD_ORIGIN` (`constants.ts:37`) | `PUBLIC_SERVER_URL` |
-| Add‑org modal | Hidden (`+layout.svelte:29`) | Shown |
-
-> Both modes exist in source and share one codebase; **neither mode is
-> `⚠️ NOT FOUND`.** The product is one app with flag‑driven divergence.
-
----
-
-## 6. End‑to‑End Flow Diagrams (text)
-
-**A. Signup → Onboarding → Workspace**
-```
-Login/Signup (apps/dashboard/.../(auth)/signup)
- → POST /api/auth/sign-up/* [signupGuard] (app.ts:106; signup-guard.ts)
- → Better Auth creates user + profile (auth.ts; create-profile.ts)
- → (auth)/onboarding: createOrganizationWithOwner (onboarding.ts:19)
- · self-host: block 2nd org, assign ENTERPRISE plan
- → completeOnboarding → enqueue 'welcome' email (onboarding.ts:121)
- → Dashboard /lms or /org/[slug]
-```
-
-**B. Author a course**
-```
-Courses → New course (course.ts)
- → add sections (section.ts) → lessons (lesson.ts) + translations (lesson-language.ts)
- → add exercises/questions (exercise.ts, question-types)
- → [optional] AI Assistant builds content (agent/*; requires provider key)
- → publish → appears on org site / widget
-```
-
-**C. Student learns → certificate (compliance)**
-```
-Invite (course/invite.ts) → accept /invite/[hash]
- → enroll (people.ts) → study lessons → mark complete (lessonCompletion)
- → submit exercises (submission.ts) → tutor grades (mark.ts)
- → completion recorded (courseCompletionRecord schema.ts:768)
- → certificate issued (packages/certificates; courseCertificateIssue schema.ts:850)
- → compliance: deadlines/renewals tracked (course/compliance.ts; internal/*)
-```
-
-**D. Public API automation (integration)**
-```
-Org → create automation key with scopes (organization/automation.ts; organizationApiKey)
- → external client: GET/POST /public-api/v1/courses
- [automationKeyMiddleware + automationKeyScopesMiddleware('public_api:*')]
- → service resolves orgId from key → returns course data (v1/courses.ts; services/v1)
-```
-
-**E. Cloud upgrade / buy AI tokens (cloud only)**
-```
-Upgrade or Buy tokens (api/polar/subscribe | buy-tokens)
- → Polar checkout
- → Polar webhook (api/polar/webhook/+server.ts)
- · subscription.created → createOrgPlan
- · order.paid (token_pack) → recordPurchase → aiCreditBalance
-```
-
----
-
-## 7. Open Questions & Gaps
-
-1. **Outbound webhooks — doc vs code mismatch.** `README.md:42–43` advertises
- "REST API + Webhooks … receive events (`certificate.issued`, `enrollment.completed`)",
- but the only webhook code is **inbound Polar billing**
- (`apps/dashboard/src/routes/api/polar/webhook`) and `prd/webhooks` is **todo**
- (`PRD-TRACKER.md:24`). No outbound event‑emission/delivery system was found in
- `apps/api/src` or `packages/db/src`. **Code wins: outbound webhooks appear unimplemented.**
- QA should not test event delivery as a shipped feature. ⚠️
-2. **SCORM support** — README does not promise it, but `prd/scorm-support` is **todo**
- (`PRD-TRACKER.md:16`). No SCORM code located. ⚠️ Treat as not shipped.
-3. **In‑app notification system** — `prd/notification-system` is **todo**
- (`PRD-TRACKER.md:9`). Transactional *email* exists (§3.28); in‑app notifications appear
- unbuilt. Verify before demoing. ⚠️
-4. **Selective exercise assignment** — `prd/selective-exercise-assignment` is **todo**
- (`PRD-TRACKER.md:15`). Assigning specific exercises to specific students may not exist;
- confirm against `exercise.ts` behavior. ⚠️
-5. **Course templates / cohorts / import variants** — `prd/course-templates`,
- `prd/course-cohorts`, `prd/course-import` are **todo** (`PRD-TRACKER.md:10,12,13`),
- yet related schema exists (`exerciseTemplate` `schema.ts:2401`; `courseImportDraft`
- `schema.ts:2603`; cohorts are DONE). Scope of what's actually wired end‑to‑end vs.
- schema‑only should be verified per feature before writing test cases. ⚠️
-6. **Anthropic in AI picker** — code supports Anthropic, but it is "not currently in the
- picker UI" (`README.md:231`, `.env.example:112`). The picker exposes Gemini + GPT‑4o;
- note the `.env.example:111` "Gemini 3 Flash" vs `README.md:228` "Gemini 2.5 Flash"
- wording inconsistency — verify the actual model id in `packages/utils/src/agent-models`.
-7. **Two operating sub‑modes within self‑host** (single‑org bootstrap vs. licensed
- Enterprise) interact with `signupGuard` and `requireLicense`. Exact UX when
- `LICENSE_KEY` is absent on self‑host (SSO/token‑auth hidden vs. 403) should be confirmed
- in the dashboard `license` feature before recording a self‑host SSO demo.
-8. **Tenant‑router / domain routing** (`apps/tenant-router`, `/domain`) was inventoried but
- not deeply traced (infra scope). Custom‑domain happy‑path is PRD‑DONE; failure/SSL
- provisioning states are unverified here.
-
----
-
-## Appendix — Supporting Infrastructure (light)
-
-These exist in source but are infra/dev tooling rather than demoable product features:
-
-- `apps/jobs` — BullMQ worker process; consumes media/email queues
- (paired with `apps/api/src/routes/jobs`, `mediaJob`/`jobStep` tables). Dev queue UI at
- `/admin/queues` (`apps/api/src/routes/admin/queues.ts`).
-- `apps/tenant-router` — multi‑tenant/custom‑domain request routing (backs §3.20).
-- `apps/course-app` / `packages/course-app` — course‑app runtime modules
- (`pnpm-workspace.yaml:4`).
-- `apps/docs` — documentation site (`pnpm docs:dev`).
-- `apps/website` — marketing site (`pnpm website:dev`).
-- `packages/core` — shared config incl. env access (`packages/core/src/config/env.ts`,
- used by `signup-guard.ts`, `license.ts`, `onboarding.ts`).
-- `packages/storybook` — UI component stories (`pnpm storybook:dev`).
-- `packages/analytics` — analytics helpers (paired with `apps/api/src/services/analytics`).
-- `packages/tsconfig` — shared TS config.
-- `packages/ui` — shared Svelte component library (`@cio/ui`); theme classes require `ui:`
- prefix in dashboard (`AGENTS.md:313`).
-- `packages/utils` — shared Zod validation, plans, license, agent‑models, constants.
-- `packages/db` — Drizzle schema/queries/migrations/seed; `pnpm --filter @cio/db db:setup:seed`
- seeds demo data and the demo login `admin@test.com` / `123456` (`README.md:215–219`).
-
----
-
-### How this audit was verified
-Every section traces to files read during the audit: `README.md`, `ARCHITECHTURE.md`,
-`AGENTS.md`, `.env.example`, `PRD-TRACKER.md`, `apps/api/src/app.ts`,
-`packages/db/src/schema.ts`, the role/RBAC middlewares, `signup-guard.ts`,
-`onboarding.ts`, `appSetup.ts`, the Polar webhook, the v1 public‑API router, and the
-license constants/middleware. No application was run and no tests were executed —
-functional testing is deferred to the QA phase per the audit brief.
diff --git a/README.md b/README.md
index 90f79fb0..1f44e7ce 100644
--- a/README.md
+++ b/README.md
@@ -255,12 +255,12 @@ Notes:
```bash
cp .env.example .env # copy env template, edit for your domain
-./run-docker-full-stack.sh
+./classroomio.sh start # pulls pre-built images; use --build to build from source
```
-The script reads root `.env` via `docker compose --env-file .env` and auto-generates a secure `PRIVATE_SERVER_KEY` (and `BETTER_AUTH_SECRET`) when missing.
+The script reads root `.env` via `docker compose --env-file .env` and auto-generates a secure `PRIVATE_SERVER_KEY` (and `BETTER_AUTH_SECRET`) when missing. Run `./classroomio.sh` with no arguments for the full lifecycle menu (install, start, stop, restart, upgrade, logs, backup).
-See [`.env.example`](.env.example) for the full list of environment variables with required/optional grouping, and [`docker/docs/SELF_HOST.md`](docker/docs/SELF_HOST.md) for the complete Docker self-hosting guide. To raise upload caps (documents, videos, assignment files), set the `UPLOAD_MAX_*_MB` vars in `.env` — see the [docs](https://classroomio.com/docs/self-hosted/docker#optional--upload-file-size-limits).
+See [`.env.example`](.env.example) for the full list of environment variables with required/optional grouping, and [`docker/docs/SELF_HOST.md`](docker/docs/SELF_HOST.md) for the complete Docker self-hosting guide. To raise upload caps (documents, videos, assignment files), set the `UPLOAD_MAX_*_MB` vars in `.env` — see the [docs](https://classroomio.com/docs/self-hosted/configuration/storage#upload-file-size-limits).
## Publishing
diff --git a/apps/docs/blume.config.ts b/apps/docs/blume.config.ts
index 070203ef..bcb0b5e2 100644
--- a/apps/docs/blume.config.ts
+++ b/apps/docs/blume.config.ts
@@ -168,8 +168,14 @@ export default defineConfig({
root: '/self-hosted',
items: [
'/self-hosted',
- {
- label: 'Platforms',
+ '/self-hosted/101',
+ '/self-hosted/architecture',
+ {
+ label: 'Install',
+ display: 'group',
+ // Pinned open — it's what most visitors came for. The other groups
+ // start collapsed (and auto-expand when they contain the current page).
+ collapsed: false,
items: [
'/self-hosted/docker',
'/self-hosted/digitalocean',
@@ -179,7 +185,9 @@ export default defineConfig({
]
},
{
- label: 'Configuration',
+ label: 'Configure',
+ display: 'group',
+ collapsed: true,
items: [
'/self-hosted/configuration',
'/self-hosted/configuration/secrets',
@@ -187,10 +195,21 @@ export default defineConfig({
'/self-hosted/configuration/storage',
'/self-hosted/configuration/csp',
'/self-hosted/configuration/integrations',
- '/self-hosted/configuration/sso',
- '/self-hosted/configuration/versions'
+ '/self-hosted/enterprise'
]
},
+ {
+ label: 'Manage',
+ display: 'group',
+ collapsed: true,
+ items: ['/self-hosted/configuration/versions', '/self-hosted/backups']
+ },
+ {
+ label: 'Troubleshoot',
+ display: 'group',
+ collapsed: true,
+ items: ['/self-hosted/troubleshooting']
+ }
]
}
]
diff --git a/apps/docs/content/docs/enterprise-sso.mdx b/apps/docs/content/docs/enterprise-sso.mdx
index 16d2d722..b518dac0 100644
--- a/apps/docs/content/docs/enterprise-sso.mdx
+++ b/apps/docs/content/docs/enterprise-sso.mdx
@@ -19,7 +19,7 @@ Set `LICENSE_KEY` in the API service environment.
Local development: `apps/api/.env`.
Docker Compose: repository root `.env`.
If you do not use Enterprise-only features, leave `LICENSE_KEY` unset.
-See [Self Hosting → SSO & licensing](/self-hosted/configuration/sso) for per-platform placement.
+See [Self Hosting → Enterprise & licensing](/self-hosted/enterprise) for per-platform placement.
:::
## Supported Providers
diff --git a/apps/docs/content/docs/self-hosted/101.mdx b/apps/docs/content/docs/self-hosted/101.mdx
new file mode 100644
index 00000000..14432c61
--- /dev/null
+++ b/apps/docs/content/docs/self-hosted/101.mdx
@@ -0,0 +1,74 @@
+---
+title: Self-hosting 101
+description: What you get when you self-host ClassroomIO, how the stack fits together, and how self-hosted differs from the cloud product.
+---
+
+ClassroomIO is an open-source learning platform you can run entirely on your own infrastructure.
+This page explains what you're actually deploying before you pick an
+[install path](/self-hosted#which-should-i-choose).
+
+## What you get
+
+The full product: the same code that powers the cloud version. Self-hosted, you run:
+
+- The **dashboard** your team and students use (courses, exercises, communities, certificates).
+- The **API** behind it.
+- A background **jobs worker** for the heavy lifting: video processing, transcription, AI course
+ generation, and email sending.
+- The infrastructure they need: **Postgres** (database), **Redis** (queues and caching), and
+ **MinIO** (S3-compatible object storage for uploads, swappable for AWS S3 or Cloudflare R2).
+
+That's the ~7 containers the Docker stack starts (MinIO ships with a small one-shot init container
+that creates its buckets). They're deliberately separate services: the parts that spike CPU and RAM
+(video transcodes) are isolated from the parts that serve your users. How they talk to each other
+is on the [Architecture](/self-hosted/architecture) page; what hardware they need is in the
+[server requirements](/self-hosted#server-requirements).
+
+## Self-hosted vs cloud
+
+| | Cloud (app.classroomio.com) | Self-hosted |
+|---|---|---|
+| Infrastructure | Managed for you | Yours — your server, your domain |
+| Your data | Hosted by ClassroomIO | Stays on your infrastructure |
+| Updates | Automatic | You choose when ([pin a version](/self-hosted/configuration/versions)) |
+| Organizations | Multiple per account, plan-based feature tiers | Single organization with all core features |
+| Enterprise features (SSO, token auth, no-tracking) | On the Enterprise plan | Via a [license key](/self-hosted/enterprise) |
+
+Self-hosted mode is controlled by one flag, `PUBLIC_IS_SELFHOSTED=true`, which is already set in
+the shipped Docker configuration. It locks the instance to a single organization with the full core
+feature set, and points invite links and emails at your own domain.
+
+**About telemetry:** self-hosted mode disables error reporting (Sentry) and the in-app feedback
+widget out of the box. Product analytics (PostHog, Umami) are only disabled by the `no-tracking`
+enterprise license feature. See [Enterprise & licensing](/self-hosted/enterprise).
+
+## How licensing works
+
+ClassroomIO is **open core** under the [AGPL-3.0 license](https://github.com/classroomio/classroomio/blob/main/LICENSE):
+
+- **Everything an LMS needs is free:** courses, students, uploads, video, AI features,
+ communities, certificates. No seat limits, no artificial caps. You never need a license key to
+ run a complete platform.
+- **A small set of enterprise features** (SSO, token auth, and no-tracking mode) are gated behind
+ a paid `LICENSE_KEY`. They exist so organizations with compliance requirements can fund the
+ project. Details on the [Enterprise & licensing](/self-hosted/enterprise) page.
+
+There is exactly one codebase and one set of Docker images. A license key unlocks features in the
+same build you already run, so you never migrate editions.
+
+## Where to next
+
+
+
+ Docker, DigitalOcean, Railway, Coolify, or Dokploy.
+
+
+ The services, how they connect, and why the jobs worker needs RAM.
+
+
+ Every environment variable the stack reads.
+
+
+ SSO, token auth, no-tracking, and how license validation works.
+
+
diff --git a/apps/docs/content/docs/self-hosted/architecture.mdx b/apps/docs/content/docs/self-hosted/architecture.mdx
new file mode 100644
index 00000000..ffd9d21d
--- /dev/null
+++ b/apps/docs/content/docs/self-hosted/architecture.mdx
@@ -0,0 +1,84 @@
+---
+title: Architecture
+description: The services in a self-hosted ClassroomIO stack, how they authenticate to each other, and why the jobs worker drives your RAM requirement.
+---
+
+Every install path (Docker, DigitalOcean, Railway, Coolify, Dokploy) runs the same topology, so
+this page applies no matter which one you picked.
+
+## The services
+
+```mermaid
+flowchart LR
+ B[Browser] -->|HTTPS| D[dashboard :3082]
+ D -->|Bearer PRIVATE_SERVER_KEY| A[api :3081]
+ A --> P[(postgres)]
+ A --> R[(redis)]
+ A --> M[(minio / S3)]
+ J[jobs worker] --> R
+ J --> P
+ J --> M
+```
+
+| Service | Image | Port | Role |
+|---------|-------|------|------|
+| `dashboard` | `classroomio/dashboard` | 3082 | The SvelteKit web app. Server-side renders pages and proxies browser API calls, so user traffic is same-origin. |
+| `api` | `classroomio/api` | 3081 | The backend. Runs **database migrations on boot**, then serves the API. |
+| `jobs` | `classroomio/jobs` | — | Background worker (BullMQ). Drains the Redis queues: video processing, transcription, AI course generation, emails, notifications, maintenance. Ships with ffmpeg. |
+| `postgres` | `postgres:16-alpine` | internal | The database. All state lives here (volume `postgres-data`). |
+| `redis` | `redis:7-alpine` | internal | Job queues and caching, persisted with AOF (volume `redis-data`). |
+| `minio` | `minio/minio` | 9000/9001 | S3-compatible object storage for uploads (volume `minio-data`). Swappable for AWS S3 / Cloudflare R2. |
+| `minio-init` | `minio/mc` | — | One-shot: creates the `videos`, `documents`, and `media` buckets, makes `media` publicly downloadable, then exits. |
+
+The app services are pinned by one variable, `CIO_VERSION`. See
+[Versions & upgrades](/self-hosted/configuration/versions).
+
+## Startup order
+
+The compose file encodes the dependency chain with healthchecks:
+
+1. **postgres** and **redis** start first. Postgres only reports healthy once it answers a real
+ query (`SELECT 1`), not just `pg_isready`, because Postgres can accept connections while still
+ in crash recovery, which would surface as
+ [57P03 sign-in errors](/self-hosted/troubleshooting#sign-in-fails-with-57p03-right-after-startup).
+2. **api** starts, runs any pending database migrations, then listens. A failed migration exits the
+ container instead of serving a half-migrated database.
+3. **dashboard** starts only after the API is healthy.
+
+This is why a first boot takes a minute or two: the API is migrating before anything responds.
+
+## The api ↔ dashboard handshake
+
+The dashboard's server side calls the API over the internal container network
+(`PRIVATE_SERVER_URL`, default `http://api:3081`) and authenticates every request with:
+
+```
+Authorization: Bearer
+```
+
+The API rejects requests where the key is missing or wrong (401), and refuses to operate without
+one configured (500). Two consequences for operators:
+
+- **The value must be identical in the api and dashboard services.** The compose file passes the
+ same `.env` value to both, and `./classroomio.sh` generates a strong one automatically. On PaaS
+ platforms where you set env per service, a mismatch is the classic cause of a dashboard that
+ renders but can't load any data.
+- The compose file **fails fast** if `PRIVATE_SERVER_KEY` (or `BETTER_AUTH_SECRET`) is empty.
+ See [Environment & secrets](/self-hosted/configuration/secrets).
+
+## The jobs worker and your RAM
+
+The jobs worker is why the [server requirements](/self-hosted#server-requirements) recommend 8 GB.
+
+It processes queues for media (probe → thumbnail → audio extraction), transcription (Whisper),
+emails, notifications, AI course generation, and periodic maintenance (including reaping stuck
+jobs). Video work runs **ffmpeg as a subprocess**, streaming files via disk rather than holding
+whole videos in memory, but transcodes still spike RAM well above the stack's idle usage. A server
+that looks comfortable at idle can OOM-kill Postgres the first time someone uploads a lecture
+video; see [Troubleshooting → OOM kills](/self-hosted/troubleshooting#containers-are-oom-killed).
+
+The worker is required, not optional: without it (or without Redis), uploads queue forever at
+"processing" and most emails never send
+([details](/self-hosted/troubleshooting#uploads-stuck-at-processing)). It also scales
+independently: the image runs all workers in one process by default, but you can override the
+container command to run, say, media processing on separate hardware from email sending.
diff --git a/apps/docs/content/docs/self-hosted/backups.mdx b/apps/docs/content/docs/self-hosted/backups.mdx
new file mode 100644
index 00000000..efef862c
--- /dev/null
+++ b/apps/docs/content/docs/self-hosted/backups.mdx
@@ -0,0 +1,96 @@
+---
+title: Backups
+description: Back up and restore a self-hosted ClassroomIO — Postgres dumps and object-storage archives, automated or manual.
+---
+
+Two things hold all of your data: the **Postgres database** (courses, users, organizations) and
+**object storage** (uploaded videos, documents, media). Back up both.
+
+## With the script
+
+```zsh title="Terminal"
+./classroomio.sh backup
+```
+
+This writes two timestamped files into `./backups/`:
+
+- `classroomio-db-.sql.gz`: a full `pg_dump` of the database.
+- `classroomio-minio-.tar.gz`: an archive of the bundled MinIO volume.
+
+If you use external S3/R2 instead of the bundled MinIO, the second step is skipped. Use your
+provider's backup or replication tools for the buckets.
+
+`./classroomio.sh upgrade` runs this backup automatically before pulling new images, so every
+upgrade has a restore point.
+
+:::warning[Copy backups off the server]
+A dead disk takes `./backups/` with it. Sync the files to another machine, an object-storage
+bucket, or your provider's snapshot system on a schedule, e.g. a nightly cron:
+
+```zsh title="crontab"
+0 3 * * * cd /path/to/classroomio && ./classroomio.sh backup && rsync -a backups/ user@other-host:classroomio-backups/
+```
+:::
+
+## Manual commands
+
+The script runs the equivalent of:
+
+```zsh title="Terminal"
+# Postgres (adjust user/db if you changed POSTGRES_USER / POSTGRES_DB)
+docker compose --env-file .env -p classroomio -f docker-compose.images.yaml \
+ exec -T postgres pg_dump -U postgres classroomio | gzip > backups/db.sql.gz
+
+# MinIO volume
+docker run --rm -v classroomio_minio-data:/data:ro -v "$PWD/backups":/backup \
+ alpine tar czf /backup/minio.tar.gz -C /data .
+```
+
+## Restoring
+
+
+
+### Stop the app services
+
+Keep Postgres running, stop everything that writes to it:
+
+```zsh title="Terminal"
+docker compose --env-file .env -p classroomio -f docker-compose.images.yaml stop api dashboard jobs
+```
+
+
+### Restore the database
+
+```zsh title="Terminal"
+gunzip -c backups/classroomio-db-.sql.gz | \
+ docker compose --env-file .env -p classroomio -f docker-compose.images.yaml \
+ exec -T postgres psql -U postgres classroomio
+```
+
+Restoring into a non-empty database can conflict with existing rows. For a clean restore, drop and
+recreate the database first (`dropdb`/`createdb` inside the postgres container), then run the
+command above.
+
+
+### Restore object storage
+
+```zsh title="Terminal"
+docker run --rm -v classroomio_minio-data:/data -v "$PWD/backups":/backup \
+ alpine sh -c "cd /data && tar xzf /backup/classroomio-minio-.tar.gz"
+```
+
+
+### Start everything again
+
+```zsh title="Terminal"
+./classroomio.sh start
+```
+
+
+
+:::danger[The fastest way to lose data is a cleanup command]
+`docker compose down -v` and `docker volume prune` **delete** the `postgres-data`, `redis-data`,
+and `minio-data` volumes. Never run them on a production host. See
+[Troubleshooting → disk space](/self-hosted/troubleshooting#no-space-left-on-device) for safe
+alternatives.
+:::
diff --git a/apps/docs/content/docs/self-hosted/configuration/csp.mdx b/apps/docs/content/docs/self-hosted/configuration/csp.mdx
index df5ed25f..9addb840 100644
--- a/apps/docs/content/docs/self-hosted/configuration/csp.mdx
+++ b/apps/docs/content/docs/self-hosted/configuration/csp.mdx
@@ -4,8 +4,8 @@ description: Allow the external CDN, font, media, and frame domains your self-ho
---
When `PUBLIC_IS_SELFHOSTED=true`, the dashboard ships a strict Content Security Policy that allows only
-`'self'`. If you load assets from external domains — a CDN, custom fonts, embedded video, an external
-media/storage host — you must add those domains, or the browser blocks them.
+`'self'`. If you load assets from external domains (a CDN, custom fonts, embedded video, an external
+media/storage host), you must add those domains, or the browser blocks them.
## Variables
@@ -29,13 +29,13 @@ ALLOWED_EXTERNAL_DOMAINS=https://fonts.googleapis.com,https://storage.yourdomain
CSP_MEDIA_SRC_DOMAINS=https://storage.yourdomain.com
```
-:::note[Runtime — no rebuild needed]
-CSP domain variables are read at **container startup**. Change them and restart the container — you do
+:::note[Runtime: no rebuild needed]
+CSP domain variables are read at **container startup**. Change them and restart the container. You do
not need to rebuild any image.
:::
:::warning[You don't need to add the API]
Normal dashboard traffic is proxied to the API **same-origin**, so the API host does not need to be in
-the CSP. Add external hosts only — your CDN, video provider, font host, and object-storage/media domain
+the CSP. Add external hosts only: your CDN, video provider, font host, and object-storage/media domain
(the most common one, so browsers can load uploaded media).
:::
diff --git a/apps/docs/content/docs/self-hosted/configuration/email.mdx b/apps/docs/content/docs/self-hosted/configuration/email.mdx
index 7c85e5aa..e5641cce 100644
--- a/apps/docs/content/docs/self-hosted/configuration/email.mdx
+++ b/apps/docs/content/docs/self-hosted/configuration/email.mdx
@@ -3,7 +3,7 @@ title: Email (SMTP)
description: Configure SMTP so ClassroomIO can send signup verification, password reset, and invite emails.
---
-ClassroomIO sends transactional email — **signup verification, password resets, and invite emails** —
+ClassroomIO sends transactional email (**signup verification, password resets, and invite emails**)
over SMTP. Without it the app still runs, but it can't send any of those messages, so have mail
credentials ready before you deploy.
@@ -33,6 +33,6 @@ whole compose stack. The jobs worker won't send invites or notifications if SMTP
## Providers
-Any standard SMTP provider works — including transactional services such as **Zoho / ZeptoMail**,
+Any standard SMTP provider works, including transactional services such as **Zoho / ZeptoMail**,
Postmark, Resend (SMTP), Amazon SES, or Mailgun. See the
[Nodemailer SMTP docs](https://nodemailer.com/smtp/) for provider-specific setup and port/TLS options.
diff --git a/apps/docs/content/docs/self-hosted/configuration/index.mdx b/apps/docs/content/docs/self-hosted/configuration/index.mdx
index 3beb99b6..61f55dd1 100644
--- a/apps/docs/content/docs/self-hosted/configuration/index.mdx
+++ b/apps/docs/content/docs/self-hosted/configuration/index.mdx
@@ -3,20 +3,21 @@ title: Configuration
description: Shared configuration reference for self-hosting ClassroomIO — secrets, email, storage, CSP, integrations, licensing, and versions.
---
-However you deploy ClassroomIO — [Docker](/self-hosted/docker), [Railway](/self-hosted/railway), or
-[Coolify](/self-hosted/coolify) — the stack reads the **same set of environment variables**. This
-section is the single source of truth for what each group of variables does. The provider guides show
-you *where* to set them for that platform; these pages explain *what* to set and *why*.
+However you deploy ClassroomIO ([Docker](/self-hosted/docker), [DigitalOcean](/self-hosted/digitalocean),
+[Railway](/self-hosted/railway), [Coolify](/self-hosted/coolify), or [Dokploy](/self-hosted/dokploy)),
+the stack reads the **same set of environment variables**. This section is the single source of truth
+for what each group of variables does. The provider guides show you *where* to set them for that
+platform; these pages explain *what* to set and *why*.
## How to read these pages
ClassroomIO is configured entirely through environment variables. On Docker they live in one root
-`.env` file that every `docker compose` command reads via `--env-file .env`; on Railway and Coolify you
-set the same variables on each service.
+`.env` file that every `docker compose` command reads via `--env-file .env`; on PaaS platforms
+(Railway, Coolify, Dokploy) you set the same variables on each service.
:::note[Value = required, blank = optional]
Throughout these pages and the provider guides, a variable shown **with a value** is one you should
-set for a working deployment. A variable left **blank after `=`** is optional — fill it in only if you
+set for a working deployment. A variable left **blank after `=`** is optional: fill it in only if you
use that feature. Leaving an optional variable blank never causes a build or boot error.
:::
@@ -24,7 +25,7 @@ use that feature. Leaving an optional variable blank never causes a build or boo
- Generate `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` — the stack won't boot without them.
+ Generate `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY`: the stack won't boot without them.
SMTP for signup verification, password resets, and invite emails.
@@ -38,7 +39,7 @@ use that feature. Leaving an optional variable blank never causes a build or boo
Google OAuth, Unsplash, OpenAI transcription and AI course generation.
-
+
Set `LICENSE_KEY` to unlock Enterprise features such as SSO.
diff --git a/apps/docs/content/docs/self-hosted/configuration/integrations.mdx b/apps/docs/content/docs/self-hosted/configuration/integrations.mdx
index 805a3c00..1ab0bd48 100644
--- a/apps/docs/content/docs/self-hosted/configuration/integrations.mdx
+++ b/apps/docs/content/docs/self-hosted/configuration/integrations.mdx
@@ -3,7 +3,7 @@ title: Integrations
description: Optional integrations for self-hosted ClassroomIO — Google OAuth, Unsplash, OpenAI, and branding.
---
-These integrations are all **optional** — ClassroomIO runs without any of them. Add a variable only if
+These integrations are all **optional**: ClassroomIO runs without any of them. Add a variable only if
you use that feature; leaving it blank never causes a build or boot error.
## Google OAuth
@@ -43,6 +43,6 @@ Enables the in-app Unsplash image search.
| `PRIVATE_APP_SUBDOMAINS` | Subdomains served by the dashboard |
:::note[Enterprise features]
-`LICENSE_KEY` unlocks Enterprise-only integrations such as SSO — see
-[SSO & licensing](/self-hosted/configuration/sso).
+`LICENSE_KEY` unlocks Enterprise-only integrations such as SSO. See
+[Enterprise & licensing](/self-hosted/enterprise).
:::
diff --git a/apps/docs/content/docs/self-hosted/configuration/secrets.mdx b/apps/docs/content/docs/self-hosted/configuration/secrets.mdx
index 29965e92..fb2d6dcd 100644
--- a/apps/docs/content/docs/self-hosted/configuration/secrets.mdx
+++ b/apps/docs/content/docs/self-hosted/configuration/secrets.mdx
@@ -16,23 +16,23 @@ openssl rand -hex 32 # PRIVATE_SERVER_KEY (must be identical in the api and
| Variable | What it's for | Where it goes |
|----------|---------------|---------------|
| `BETTER_AUTH_SECRET` | Signs authentication sessions. | The **api** service. |
-| `PRIVATE_SERVER_KEY` | Authenticates server-side dashboard → API requests. | **Both** the api and dashboard — the value must be **identical** in each. |
+| `PRIVATE_SERVER_KEY` | Authenticates server-side dashboard → API requests. | **Both** the api and dashboard; the value must be **identical** in each. |
:::danger[The stack won't start without them]
-The compose file **fails fast** if `BETTER_AUTH_SECRET` or `PRIVATE_SERVER_KEY` is empty — the
+The compose file **fails fast** if `BETTER_AUTH_SECRET` or `PRIVATE_SERVER_KEY` is empty: the
containers refuse to boot rather than run with a predictable, guessable session-signing secret. Set
both to a strong random value (e.g. `openssl rand -hex 32`).
:::
:::note[Who generates them]
-Only the source-build path's `run-docker-full-stack.sh` auto-generates these when missing. The
-pre-built image path, Railway, and Coolify do **not** run that script — you generate the secrets
-yourself and paste them in.
+On Docker, `./classroomio.sh` auto-generates both secrets when they're missing (on install and
+start, for the pull and build paths alike). Railway, Coolify, and Dokploy do **not** run that
+script. There you generate the secrets yourself and paste them in.
:::
## Database credentials
-Postgres runs inside the stack (or as a managed plugin on Railway). Set a strong password — the
+Postgres runs inside the stack (or as a managed plugin on Railway). Set a strong password. The
defaults are for local use only.
| Variable | Description | Default |
@@ -41,6 +41,26 @@ defaults are for local use only.
| `POSTGRES_USER` | Database user | `postgres` |
| `POSTGRES_PASSWORD` | Database password — set a **strong** value in production | `postgres` |
+### Rotating `POSTGRES_PASSWORD` after install
+
+:::danger[Editing `.env` alone doesn't rotate it]
+The official Postgres image only reads `POSTGRES_PASSWORD` when it initializes a **fresh,
+empty** data directory. Once `./classroomio.sh install` has run once, the data volume
+already exists — changing `POSTGRES_PASSWORD` in `.env` afterward and restarting does
+**not** change the database's actual password. It only changes the password `api`/`jobs`
+*attempt* to connect with, so a mismatch breaks every DB connection.
+
+To actually rotate it after install:
+
+1. Set the new password inside Postgres itself:
+ ```zsh title="Terminal"
+ docker compose --env-file .env -p classroomio -f docker-compose.images.yaml \
+ exec postgres psql -U postgres -c "ALTER ROLE postgres WITH PASSWORD '';"
+ ```
+2. Update `POSTGRES_PASSWORD` in `.env` to the same value.
+3. Run `./classroomio.sh restart` to recreate `api`/`jobs`/`postgres` with the matching value.
+:::
+
## Mode & routing basics
| Variable | Description | Default |
@@ -50,7 +70,7 @@ defaults are for local use only.
| `DASHBOARD_ORIGIN` | Public dashboard origin, used as the auth base URL and in invite/email links | `http://localhost:3082` |
:::warning[Use an `https://` origin in production]
-`DASHBOARD_ORIGIN` (and `ORIGIN` on the dashboard) must be the real `https://` URL your users visit —
+`DASHBOARD_ORIGIN` (and `ORIGIN` on the dashboard) must be the real `https://` URL your users visit:
auth session cookies are `Secure`, so login silently fails over plain HTTP on any public domain. See
[Self Hosting → HTTPS is required in production](/self-hosted#https-is-required-in-production).
:::
diff --git a/apps/docs/content/docs/self-hosted/configuration/sso.mdx b/apps/docs/content/docs/self-hosted/configuration/sso.mdx
deleted file mode 100644
index 935d4576..00000000
--- a/apps/docs/content/docs/self-hosted/configuration/sso.mdx
+++ /dev/null
@@ -1,34 +0,0 @@
----
-title: SSO & licensing
-description: Set LICENSE_KEY to unlock Enterprise features such as SSO on your self-hosted ClassroomIO.
----
-
-Enterprise-only features — **SSO**, **token auth**, and no-tracking mode — require an Enterprise
-**license key**. If you don't use any Enterprise feature, you don't need a key; leave `LICENSE_KEY`
-unset and everything else works.
-
-## Setting `LICENSE_KEY`
-
-`LICENSE_KEY` is read by the **api** service. Set it wherever that service takes its environment:
-
-| Deployment | Where to set it |
-|------------|-----------------|
-| Local development | `apps/api/.env` |
-| Docker Compose | repository root `.env` |
-| Railway | the `cio-api` service environment |
-| Coolify | the resource's environment variables |
-
-```zsh title=".env"
-LICENSE_KEY=
-```
-
-## Getting a license
-
-[Book a call](http://cal.com/classroomio/enterprise) to get an Enterprise license key.
-
-## Feature docs
-
-Once the key is set, configure the features themselves:
-
-- [Enterprise SSO](/enterprise-sso) — Single Sign-On with OIDC (Okta, Azure AD, and more).
-- [Token Auth](/token-auth) — issue signed tokens to log users in from your own app.
diff --git a/apps/docs/content/docs/self-hosted/configuration/storage.mdx b/apps/docs/content/docs/self-hosted/configuration/storage.mdx
index 241ddd50..ab729614 100644
--- a/apps/docs/content/docs/self-hosted/configuration/storage.mdx
+++ b/apps/docs/content/docs/self-hosted/configuration/storage.mdx
@@ -7,7 +7,7 @@ ClassroomIO stores uploaded files and videos in **object storage**. You have two
**bundled MinIO** alongside the stack, or point ClassroomIO at an **external S3-compatible** service
(AWS S3, Cloudflare R2, etc.). Both speak the S3 API and use the same `OBJECT_STORAGE_*` variables.
-## Option 1 — Bundled MinIO (default)
+## Option 1: Bundled MinIO (default)
The Docker and Coolify compose files include **MinIO** under the `minio` profile, and the startup
script auto-configures it. You only change these for custom credentials.
@@ -23,17 +23,26 @@ script auto-configures it. You only change these for custom credentials.
| `OBJECT_STORAGE_FORCE_PATH_STYLE` | `true` (required for MinIO) |
| `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` | `http://localhost:9000/media` |
-:::warning[MinIO is your durability boundary]
-Single-node MinIO has no redundancy — its volume is the only thing protecting your uploads. Give it
-**persistent storage** and **back it up**. If you'd rather not operate storage yourself, use external
-**Cloudflare R2 / AWS S3** instead (R2 has no egress fees) — see below.
+:::note[Changing your domain later]
+If you change `DASHBOARD_ORIGIN` after Install, `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` is
+re-derived from it automatically the next time you run `./classroomio.sh restart` (or
+`start`/`upgrade`). If you've bundled MinIO but manually pinned
+`OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` to a custom media CDN, note that a domain change will
+overwrite it back to `/media` — re-set it afterward if you need the custom
+value.
:::
-## Option 2 — External S3 / Cloudflare R2
+:::warning[MinIO is your durability boundary]
+Single-node MinIO has no redundancy: its volume is the only thing protecting your uploads. Give it
+**persistent storage** and **back it up**. If you'd rather not operate storage yourself, use external
+**Cloudflare R2 / AWS S3** instead (R2 has no egress fees); see below.
+:::
+
+## Option 2: External S3 / Cloudflare R2
The `OBJECT_STORAGE_*` variables speak the S3 API, so they work with **any S3-compatible provider**,
including Cloudflare R2 and AWS S3. Point them at the provider and start **without** the bundled MinIO
-(`./run-docker-full-stack.sh --no-minio`, or leave the `minio` profile off).
+(`./classroomio.sh start --no-minio`, or leave the `minio` profile off).
| Variable | Description |
|----------|-------------|
@@ -48,7 +57,7 @@ including Cloudflare R2 and AWS S3. Point them at the provider and start **witho
:::note[Legacy `CLOUDFLARE_*` variables]
ClassroomIO also accepts a legacy Cloudflare R2 config (`CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_ACCESS_KEY`,
`CLOUDFLARE_SECRET_ACCESS_KEY`, …). It is a **fallback** used only when the `OBJECT_STORAGE_*` variables
-are absent — prefer `OBJECT_STORAGE_*` for new deployments (it covers R2 too). For step-by-step R2 bucket
+are absent; prefer `OBJECT_STORAGE_*` for new deployments (it covers R2 too). For step-by-step R2 bucket
and CORS setup, see the [Cloudflare Setup](/guides/contributing/cloudflare-setup) guide.
:::
diff --git a/apps/docs/content/docs/self-hosted/configuration/versions.mdx b/apps/docs/content/docs/self-hosted/configuration/versions.mdx
index 40159e0d..4facc1df 100644
--- a/apps/docs/content/docs/self-hosted/configuration/versions.mdx
+++ b/apps/docs/content/docs/self-hosted/configuration/versions.mdx
@@ -9,7 +9,7 @@ ClassroomIO publishes versioned images. `CIO_VERSION` controls which tag you run
- **Pin an exact release** (e.g. `1.0.0`) in production, so a merge to `main` never restarts you onto
changed images unattended.
-- `latest` is the freshest `main` build — it moves on **every merge** (rolling; may include unreleased
+- `latest` is the freshest `main` build: it moves on **every merge** (rolling; may include unreleased
code). The version tags (`1.0.0`, `1.0`, `1`) are the immutable, pinned releases.
```zsh title=".env"
@@ -25,7 +25,13 @@ Before deploying, confirm the tag you set actually exists at
### Pre-built images (Docker)
-Bump `CIO_VERSION` in `.env`, then re-pull and recreate:
+```zsh title="Terminal"
+./classroomio.sh upgrade
+```
+
+The script [backs up](/self-hosted/backups) Postgres and MinIO first, lets you pin a new
+`CIO_VERSION`, then re-pulls and recreates. Running compose by hand is equivalent (minus the
+backup): bump `CIO_VERSION` in `.env`, then:
```zsh title="Terminal"
docker compose -f docker-compose.images.yaml --env-file .env --profile minio pull
@@ -34,12 +40,15 @@ docker compose -f docker-compose.images.yaml --env-file .env --profile minio up
### Build from source (Docker)
-`git pull`, then re-run `./run-docker-full-stack.sh --build` to rebuild.
+`git pull`, then re-run `./classroomio.sh upgrade --build` to rebuild. Like the pre-built path,
+this backs up Postgres and MinIO first, then rebuilds and restarts. (`start --build` also
+rebuilds, but skips the backup step — use `upgrade --build` when upgrading an existing
+deployment with real data.)
-### Railway / Coolify
+### Railway / Coolify / Dokploy / DigitalOcean
-Set `CIO_VERSION` to the newer release and redeploy. Because you pin a version, a new release never
-restarts you onto changed images unattended.
+Set `CIO_VERSION` to the newer release and redeploy (on a DigitalOcean-style VPS: `dc pull && dc up -d`).
+Because you pin a version, a new release never restarts you onto changed images unattended.
## Rolling back
diff --git a/apps/docs/content/docs/self-hosted/coolify.mdx b/apps/docs/content/docs/self-hosted/coolify.mdx
index 73908285..99bc10d9 100644
--- a/apps/docs/content/docs/self-hosted/coolify.mdx
+++ b/apps/docs/content/docs/self-hosted/coolify.mdx
@@ -3,7 +3,7 @@ title: Coolify
description: Deploy ClassroomIO on Coolify using the pull-based Docker Compose stack, with a video walkthrough.
---
-[Coolify](https://coolify.io) is a self-hosted PaaS (an open-source Heroku/Netlify alternative) you run on your own VPS. It runs ClassroomIO from the published `classroomio/*` images — your Coolify host **pulls** them and never builds anything.
+[Coolify](https://coolify.io) is a self-hosted PaaS (an open-source Heroku/Netlify alternative) you run on your own VPS. It runs ClassroomIO from the published `classroomio/*` images: your Coolify host **pulls** them and never builds anything.
## Watch the walkthrough
@@ -40,7 +40,7 @@ This uses a **Docker Compose resource**: you paste the compose file and set ever
### Create a project and add a Docker Compose resource
-Log in to Coolify and **create a project**. When you add a resource, Coolify asks what type it is — GitHub, a Docker image, a service, or a **Docker Compose** file. Choose **Docker Compose**, then **paste the contents of [`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml)** into the editor.
+Log in to Coolify and **create a project**. When you add a resource, Coolify asks what type it is: GitHub, a Docker image, a service, or a **Docker Compose** file. Choose **Docker Compose**, then **paste the contents of [`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml)** into the editor.

@@ -74,13 +74,13 @@ SMTP_SENDER=ClassroomIO
# Object storage + domains: see the full reference below
```
-👉 The essentials above get you started, but the complete list of what to set — object storage, domains, and every optional variable — is in the [**Environment variable reference**](#environment-variable-reference) below. Copy that full block before you deploy.
+The essentials above get you started, but the complete list of what to set (object storage, domains, and every optional variable) is in the [**Environment variable reference**](#environment-variable-reference) below. Copy that full block before you deploy.
:::note[How to read the reference: value = required, blank = optional]
There's no way to guess which variables Coolify needs, so the [Environment variable
reference](#environment-variable-reference) spells it out. The rule is simple: **every variable
shown with a value must be set** for a smooth deployment (secrets, database, domains, object
-storage, and SMTP are all needed). **Every variable left blank after `=` is optional** — fill it in
+storage, and SMTP are all needed). **Every variable left blank after `=` is optional**: fill it in
only if you use that integration. Leaving an optional one blank never causes a build or boot error.
:::
@@ -93,7 +93,7 @@ only if you use that integration. Leaving an optional one blank never causes a b
`CIO_VERSION` chooses the tag pulled for `classroomio/{api,dashboard,jobs}`. **Confirm the tag you use actually exists** at [hub.docker.com/r/classroomio](https://hub.docker.com/r/classroomio/) before deploying.
:::note[Pin a released version]
-Set `CIO_VERSION=1.0.0` to pin an exact release. `latest` is the freshest `main` build — it moves
+Set `CIO_VERSION=1.0.0` to pin an exact release. `latest` is the freshest `main` build: it moves
on every merge (rolling; may include unreleased code), while the version tags (`1.0.0`, `1.0`, `1`)
are immutable, pinned releases. Pinning an exact release makes upgrades deliberate and deploys
reproducible.
@@ -107,7 +107,7 @@ reproducible.
Open the **dashboard** service → **Settings** and set its domain, including the internal port, e.g. `https://dash.yourdomain.com:3082`. Coolify maps that FQDN to the container's port `3082` and issues the certificate through its proxy.
-The dashboard proxies API calls server-side, so you normally only expose the dashboard publicly — the `api` service stays internal.
+The dashboard proxies API calls server-side, so you normally only expose the dashboard publicly. The `api` service stays internal.

@@ -115,7 +115,7 @@ The dashboard proxies API calls server-side, so you normally only expose the das
### Deploy
-Click **Deploy**. Coolify pulls `classroomio/{api,dashboard,jobs}:${CIO_VERSION}`, starts Postgres + Redis, the API container runs database setup on first boot, then the dashboard and the **jobs worker** come up. Watch the **logs** — they tell you when every service is up.
+Click **Deploy**. Coolify pulls `classroomio/{api,dashboard,jobs}:${CIO_VERSION}`, starts Postgres + Redis, the API container runs database setup on first boot, then the dashboard and the **jobs worker** come up. Watch the **logs**: they tell you when every service is up.

@@ -123,7 +123,7 @@ Click **Deploy**. Coolify pulls `classroomio/{api,dashboard,jobs}:${CIO_VERSION}
### Open the app and sign up
-Once the logs show the stack is healthy, open your dashboard URL (`https://dash.yourdomain.com`) in a new browser tab and **sign up**. The **first** account becomes the organization **admin**. Every signup or sign-in after that joins as a student — unless you invite them with a specific role.
+Once the logs show the stack is healthy, open your dashboard URL (`https://dash.yourdomain.com`) in a new browser tab and **sign up**. The **first** account becomes the organization **admin**. Every signup or sign-in after that joins as a student, unless you invite them with a specific role.

@@ -132,11 +132,11 @@ Once the logs show the stack is healthy, open your dashboard URL (`https://dash.
## Environment variable reference
-You set these on the resource's Environment Variables page (Step 3). This is the complete picture — copy the block below, then replace the placeholder values with your own.
+You set these on the resource's Environment Variables page (Step 3). This is the complete picture: copy the block below, then replace the placeholder values with your own.
:::note[Value = required, blank = optional]
**Every variable shown with a value below is required** for a working deployment. **Every variable
-left blank after `=` is optional** — fill it in only if you use that feature. A blank optional
+left blank after `=` is optional**: fill it in only if you use that feature. A blank optional
variable never causes a build or boot error, so don't put junk into fields you aren't using.
:::
@@ -184,7 +184,7 @@ SMTP_PASSWORD=
SMTP_SENDER=ClassroomIO
# ── Optional — leave blank unless you need them ──
-CIO_VERSION=1.0.0 # pinned release; blank = latest (rolling main build)
+CIO_VERSION= # pin a release, e.g. 1.0.0; blank = latest (rolling main build)
GOOGLE_CLIENT_ID= # Google OAuth sign-in
GOOGLE_CLIENT_SECRET=
OPENAI_API_KEY= # transcription + AI course generation
@@ -201,7 +201,7 @@ OBJECT_STORAGE_REGION= # only for some external S3 providers
SKIP_DB_SETUP= # blank = run DB setup on first boot (leave blank)
```
-### Required — set all of these
+### Required: set all of these
| Variable | What it's for |
|----------|---------------|
@@ -229,10 +229,10 @@ SKIP_DB_SETUP= # blank = run DB setup on first boot (leave blank)
Of the above, `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` are the two the compose file
**fails fast** on: leave either empty and the containers refuse to boot (so ClassroomIO never runs
with a predictable session-signing secret). The rest won't abort the boot, but the deployment
-won't work correctly without them — set them all.
+won't work correctly without them. Set them all.
:::
-### Optional — leave blank unless you need them
+### Optional: leave blank unless you need them
| Variable | Enables |
|----------|---------|
@@ -255,7 +255,7 @@ Enable the **bundled MinIO** with `COMPOSE_PROFILES=minio` and Coolify runs it a
(`OBJECT_STORAGE_PUBLIC_ENDPOINT` / `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`) so browsers can load media.
:::warning[MinIO is your durability boundary]
-Single-node MinIO has no redundancy — its volume is the only thing protecting your uploads, so give it
+Single-node MinIO has no redundancy: its volume is the only thing protecting your uploads, so give it
persistent storage in Coolify and back it up.
:::
diff --git a/apps/docs/content/docs/self-hosted/digitalocean.mdx b/apps/docs/content/docs/self-hosted/digitalocean.mdx
index 2ca69d5c..34ae4f1b 100644
--- a/apps/docs/content/docs/self-hosted/digitalocean.mdx
+++ b/apps/docs/content/docs/self-hosted/digitalocean.mdx
@@ -3,7 +3,7 @@ title: DigitalOcean
description: Deploy ClassroomIO to a DigitalOcean droplet with Docker Compose and Caddy, including automatic HTTPS.
---
-Run ClassroomIO on a plain **DigitalOcean droplet** — no PaaS in between. You get the
+Run ClassroomIO on a plain **DigitalOcean droplet**, no PaaS in between. You get the
[pull-based Docker Compose stack](/self-hosted/docker) plus a [Caddy](https://caddyserver.com)
reverse proxy that obtains and renews HTTPS certificates automatically.
@@ -12,7 +12,7 @@ AWS Lightsail**, or any Ubuntu 24.04 server you can SSH into.
:::note[How this differs from the PaaS guides]
[Railway](/self-hosted/railway), [Coolify](/self-hosted/coolify) and [Dokploy](/self-hosted/dokploy)
-terminate HTTPS for you. On a raw VPS you provide that yourself — this page covers the reverse proxy,
+terminate HTTPS for you. On a raw VPS you provide that yourself: this page covers the reverse proxy,
the certificates, and closing your internal ports off from the internet.
:::
@@ -35,12 +35,12 @@ Prefer to follow along? This video walks through the whole deployment end to end
| A | `app` | your droplet's IP |
| A | `s3` | your droplet's IP |
-- An **SMTP provider** for signup verification, password resets, and invites — see
+- An **SMTP provider** for signup verification, password resets, and invites. See
[Configuration → Email (SMTP)](/self-hosted/configuration/email).
:::note[No domain? You can still get real HTTPS]
Use [sslip.io](https://sslip.io), a public wildcard-DNS service where `..sslip.io`
-resolves to that IP — so no records are needed. In Step 1 set
+resolves to that IP, so no records are needed. In Step 1 set
`export DOMAIN=.sslip.io` instead of your own domain, and **every other command on
this page is identical**. Read the [caveat](#a-note-on-sslipio) before using it for anything real.
:::
@@ -152,7 +152,7 @@ sudo usermod -aG docker cio
```
:::warning[Use the literal username, not `$USER`]
-`$USER` expands to whoever runs the shell — run it from a root session and it grants **root** rather
+`$USER` expands to whoever runs the shell. Run it from a root session and it grants **root** rather
than your user, and every later Docker command fails with `permission denied` on
`/var/run/docker.sock`. Check with `getent group docker`.
:::
@@ -168,7 +168,7 @@ id | grep -o docker # -> docker
docker compose version # must be v2.24.0 or newer
```
-Compose **v2.24.0+** is required — Step 6 uses the `!override` YAML tag. Older versions
+Compose **v2.24.0+** is required: Step 6 uses the `!override` YAML tag. Older versions
*concatenate* port lists instead of replacing them, which would leave your internal services exposed
to the internet.
@@ -181,8 +181,8 @@ mkdir -p ~/classroomio && cd ~/classroomio
curl -O https://raw.githubusercontent.com/classroomio/classroomio/main/docker-compose.images.yaml
```
-This is the same [pull-based stack](/self-hosted/docker#run-from-pre-built-images-recommended) used
-by the other guides — it pulls the published `classroomio/*` images and builds nothing.
+This is the same [pull-based stack](/self-hosted/docker#quick-start) used
+by the other guides. It pulls the published `classroomio/*` images and builds nothing.
@@ -242,11 +242,11 @@ Then fill in your SMTP credentials with `nano .env`.
Three of these break deployments more often than all the others combined:
-- **`OBJECT_STORAGE_PUBLIC_ENDPOINT`** — where the browser fetches video from. Left at the template
+- **`OBJECT_STORAGE_PUBLIC_ENDPOINT`**: where the browser fetches video from. Left at the template
default of `localhost`, every video URL points at the *visitor's own machine*.
-- **`DASHBOARD_ORIGIN`** — must match the browser URL exactly, `https://` included, no trailing
+- **`DASHBOARD_ORIGIN`**: must match the browser URL exactly, `https://` included, no trailing
slash. A mismatch breaks login with CSRF errors.
-- **`CSP_MEDIA_SRC_DOMAINS` / `CSP_CONNECT_SRC_DOMAINS`** — because storage is on a second hostname,
+- **`CSP_MEDIA_SRC_DOMAINS` / `CSP_CONNECT_SRC_DOMAINS`**: because storage is on a second hostname,
the browser blocks its media unless these allow it. Symptom: the page looks fine, images and video
silently don't load, and the only clue is in the browser console.
@@ -261,7 +261,7 @@ Every remaining variable is documented in [Configuration](/self-hosted/configura
### Add Caddy and close the internal ports
-Right now each part of ClassroomIO runs on its own port number — the dashboard on `3082`, file
+Right now each part of ClassroomIO runs on its own port number: the dashboard on `3082`, file
storage on `9000`. That leaves two problems: nobody wants to visit `yoursite.com:3082`, and neither
port is encrypted, so there would be no padlock in the browser.
@@ -328,14 +328,14 @@ EOF
```
Those eight lines are the entire HTTPS setup. The first time someone visits your site, Caddy asks
-Let's Encrypt for a free certificate, installs it, and from then on renews it before it expires —
-you never touch it again.
+Let's Encrypt for a free certificate, installs it, and from then on renews it before it expires.
+You never touch it again.
You could use nginx instead, but you'd also be installing certbot, setting up a renewal timer, and
hand-writing the forwarding rules that Caddy applies by default.
:::warning[Your firewall does not cover Docker]
-You set up a firewall back in Step 3. It does **not** apply to Docker containers — Docker writes its
+You set up a firewall back in Step 3. It does **not** apply to Docker containers: Docker writes its
own networking rules underneath it, so a container that publishes a port stays reachable from the
internet even when `ufw` says that port is blocked.
@@ -367,7 +367,7 @@ dc pull
:::warning[This downloads ~12 GB and takes 10–20 minutes]
The images are large (`api` 3.7 GB, `dashboard` 3.9 GB, `jobs` 4.2 GB). On a 2-vCPU droplet the
-**progress bars sit still for minutes at a time — this is normal, it is not frozen.** Don't cancel it.
+**progress bars sit still for minutes at a time. This is normal, it is not frozen.** Don't cancel it.
:::
@@ -378,7 +378,7 @@ The images are large (`api` 3.7 GB, `dashboard` 3.9 GB, `jobs` 4.2 GB). On a 2-v
dc up -d
```
-Services start in dependency order — Postgres and Redis must report healthy before the API starts,
+Services start in dependency order: Postgres and Redis must report healthy before the API starts,
because the API runs database migrations on boot. A full start takes about **40 seconds**.
```zsh title="Terminal"
@@ -396,7 +396,7 @@ cio-postgres Up About a minute (healthy)
cio-redis Up About a minute (healthy)
```
-`cio-minio-init` is absent on purpose — it creates the storage buckets and exits. Confirm it worked:
+`cio-minio-init` is absent on purpose: it creates the storage buckets and exits. Confirm it worked:
```zsh title="Terminal"
docker logs cio-minio-init | tail -4
@@ -425,19 +425,19 @@ for p in 3081 3082 9000 9001; do
done
```
-All four must report `no response`. If any is reachable, your Compose version ignored `!override` —
-check that `docker compose version` is v2.24.0 or newer.
+All four must report `no response`. If any is reachable, your Compose version ignored `!override`.
+Check that `docker compose version` is v2.24.0 or newer.
### Sign up
-Open `https://app.` and create the first account — it becomes the organization
+Open `https://app.` and create the first account: it becomes the organization
**admin**. Every signup after that joins as a student unless you invite them with a specific role.
Then verify uploads end to end: create a course, upload an image, and upload a short video. Watch the
worker process it with `dc logs -f jobs`. In your browser's DevTools the media URLs must point at
-`https://s3./...` — if you see `localhost:9000`, `OBJECT_STORAGE_PUBLIC_ENDPOINT` is
+`https://s3./...`. If you see `localhost:9000`, `OBJECT_STORAGE_PUBLIC_ENDPOINT` is
wrong.
@@ -453,7 +453,7 @@ dc down # stop, keeping all data
```
:::danger[Never use `dc down -v`]
-The `-v` flag deletes the `postgres-data`, `redis-data` and `minio-data` volumes — your database and
+The `-v` flag deletes the `postgres-data`, `redis-data` and `minio-data` volumes: your database and
every upload. Plain `dc down` is always safe.
:::
@@ -498,7 +498,7 @@ own. **For production, use your own domain.**
```
3. Change the two site addresses in `~/classroomio/Caddyfile`.
-4. Apply, then clear cached media URLs — presigned URLs are cached in Redis for 50 minutes, so
+4. Apply, then clear cached media URLs: presigned URLs are cached in Redis for 50 minutes, so
without this the old hostname keeps being served:
```zsh title="Terminal"
@@ -506,10 +506,14 @@ own. **For production, use your own domain.**
docker exec cio-redis sh -c "redis-cli --scan --pattern 'presign:download:*' | xargs -r redis-cli del"
```
-Existing uploads keep working — media URLs are generated per request from the new endpoint.
+Existing uploads keep working. Media URLs are generated per request from the new endpoint.
## Troubleshooting
+These are the droplet-specific issues. For general stack problems (sign-in errors right after
+startup, stuck uploads, OOM kills, disk space), see the shared
+[Troubleshooting](/self-hosted/troubleshooting) page.
+
| Symptom | Cause |
| ------- | ----- |
| `permission denied` on `/var/run/docker.sock` | Your user isn't in the `docker` group, or you haven't logged out and back in since adding it |
@@ -549,8 +553,8 @@ This deployment reads the same variables as every other self-hosted install:
## Upgrades
-Check which versions exist, then pin one — never guess a tag, as an unpublished one fails the pull
-with `manifest unknown`:
+Check which versions exist, then pin one: never guess a tag, since an unpublished one fails the pull
+with `manifest unknown`.
```zsh title="Terminal"
curl -s "https://hub.docker.com/v2/repositories/classroomio/dashboard/tags?page_size=20&ordering=last_updated" \
@@ -561,5 +565,5 @@ dc pull && dc up -d
```
Migrations run automatically on API startup. Roll back by setting `CIO_VERSION` to the previous
-release and repeating — which is why you pin an exact version rather than `latest`. See
+release and repeating, which is why you pin an exact version rather than `latest`. See
[Configuration → Versions & upgrades](/self-hosted/configuration/versions).
diff --git a/apps/docs/content/docs/self-hosted/docker.mdx b/apps/docs/content/docs/self-hosted/docker.mdx
index 16cae9ab..4cbefe98 100644
--- a/apps/docs/content/docs/self-hosted/docker.mdx
+++ b/apps/docs/content/docs/self-hosted/docker.mdx
@@ -1,37 +1,129 @@
---
title: Docker
-description: Self-host ClassroomIO with Docker — pull pre-built images (recommended) or build from source.
+description: Self-host ClassroomIO with Docker — one script installs, starts, upgrades, and backs up the whole stack.
---
-Docker is one way to self-host ClassroomIO. There are two paths:
-
-- **Run from pre-built images (recommended)** — pull the published `classroomio/{api,dashboard,jobs}` images. You only need one compose file and a `.env`; no repository clone and no heavy local build.
-- **Build from source** — clone the repo and build the images yourself (needs ~8 GB RAM for the dashboard build).
+Run ClassroomIO anywhere Docker runs. One script manages the entire lifecycle: you copy it,
+run it, and pick a number. New to self-hosting? Start with
+[Self-hosting 101](/self-hosted/101) to see what you're deploying.

-## Run from Pre-built Images (recommended)
+## Prerequisites
-### Prerequisites
+- Docker installed and running (Compose v2 or the legacy `docker-compose` both work).
+- A server that meets the [requirements](/self-hosted#server-requirements) (8 GB RAM recommended).
-- Docker installed and running.
+## Quick start
-### Steps
-
-1. Download the pull-based compose file and an env template:
+
+
+### Get the script
```zsh title="Terminal"
-curl -O https://raw.githubusercontent.com/classroomio/classroomio/main/docker-compose.images.yaml
-curl -o .env https://raw.githubusercontent.com/classroomio/classroomio/main/.env.example
+mkdir classroomio && cd classroomio
+curl -fsSLO https://classroomio.com/classroomio.sh
+chmod +x classroomio.sh
+```
+
+
+### Run it and pick Install
+
+```zsh title="Terminal"
+./classroomio.sh
```
-2. Edit `.env`. At minimum set `CIO_VERSION` (pin a release), `DASHBOARD_ORIGIN`, the auth secrets, and SMTP:
+Choose **1) Install**. The script downloads the compose file, creates `.env` from the template,
+generates the auth secrets and MinIO credentials for you, then pulls the images and starts the
+stack. Re-running Install is safe: it never overwrites an existing `.env` or compose file.
+
+
+### Edit the required values
+
+Everything else is auto-generated or defaulted. For a real deployment, set only:
+
+```zsh title=".env"
+CIO_VERSION=1.0.0 # pin a release for production
+DASHBOARD_ORIGIN=https://app.yourdomain.com # your public https:// URL
+POSTGRES_PASSWORD= # required in production
+SMTP_HOST= # email — signup, resets, invites
+SMTP_PORT=
+SMTP_USER=
+SMTP_PASSWORD=
+SMTP_SENDER=
+```
+
+Then apply: `./classroomio.sh restart`
+
+:::warning[Changing `POSTGRES_PASSWORD` after Install does nothing on its own]
+Postgres only applies `POSTGRES_PASSWORD` the first time it initializes its data volume. If
+you're editing `.env` after Install has already run, see [rotating it after
+install](/self-hosted/configuration/secrets#rotating-postgres_password-after-install) —
+just restarting/recreating containers will break the DB connection instead of fixing it.
+:::
+
+
+
+The dashboard is at http://localhost:3082, the API at http://localhost:3081, and the MinIO console
+at http://localhost:9001 (credentials are in your `.env`).
+
+:::warning[Pin `CIO_VERSION` in production]
+The default tags track moving builds: `latest` changes on every merge to `main`. Pin an exact
+release (e.g. `CIO_VERSION=1.4.2`) so an upstream merge can never change your deployment
+unannounced. The script warns you about this too. See
+[Versions & upgrades](/self-hosted/configuration/versions).
+:::
+
+:::note[Serving on a real domain?]
+Use an `https://` origin: session cookies are `Secure`, so
+[login silently fails over plain HTTP](/self-hosted#https-is-required-in-production).
+:::
+
+## The menu
+
+| Option | What it does |
+|--------|--------------|
+| 1 Install | First-time setup: fetch files, create `.env`, generate secrets, pull and start. |
+| 2 Start | Start the stack (also fills in any missing secrets first). |
+| 3 Stop | Stop containers. Data volumes are kept. |
+| 4 Restart | Restart containers, e.g. after editing `.env`. |
+| 5 Upgrade | **Backs up first**, then pulls newer images and restarts. |
+| 6 View logs | Follow logs for all services or one (`./classroomio.sh logs api`). |
+| 7 Backup | Dump Postgres and archive the MinIO volume into `./backups/`. See [Backups](/self-hosted/backups). |
+
+Every option is also a subcommand (`./classroomio.sh upgrade`), so you can script or cron it.
+
+
+
+To run local code changes, clone the repo and use the same script with `--build`
+(needs `git` and ~8 GB RAM for the dashboard build):
+
+```zsh title="Terminal"
+git clone https://github.com/classroomio/classroomio.git
+cd classroomio
+cp .env.example .env
+./classroomio.sh start --build
+```
+
+Without `--build` the script pulls the published `classroomio/*` images at your `CIO_VERSION`,
+which is the faster default. `--no-minio` excludes the bundled MinIO if you use external
+[S3/R2 storage](/self-hosted/configuration/storage).
+
+
+
+## Configuration reference
+
+Docker self-hosting uses a single root `.env` file: every compose command reads it via
+`--env-file .env`, and the compose file itself tracks `main` (it's just the stack topology;
+`CIO_VERSION` pins the app images).
+
+
```zsh title=".env"
CIO_VERSION=1.0.0 # pin a release (not "latest") so upgrades are deliberate
DASHBOARD_ORIGIN=https://app.yourdomain.com
-BETTER_AUTH_SECRET=
-PRIVATE_SERVER_KEY= # must be identical in the api and dashboard
+BETTER_AUTH_SECRET= # auto-generated by the script if left blank
+PRIVATE_SERVER_KEY= # auto-generated; must be identical in api and dashboard
POSTGRES_PASSWORD=
# Email (required for signup/reset/invites)
SMTP_HOST=
@@ -41,78 +133,13 @@ SMTP_PASSWORD=
SMTP_SENDER=
```
-:::danger[Set strong secrets — the stack won't start without them]
-This path does **not** run `run-docker-full-stack.sh`, so secrets are not auto-generated. You must set
-`BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` yourself, or the compose file fails fast. See
-[Configuration → Environment & secrets](/self-hosted/configuration/secrets).
-:::
+See [`.env.example`](https://github.com/classroomio/classroomio/blob/main/.env.example) for the
+full annotated template.
-3. Pull and start the stack (MinIO is included via the `minio` profile):
+
-```zsh title="Terminal"
-docker compose -f docker-compose.images.yaml --env-file .env --profile minio pull
-docker compose -f docker-compose.images.yaml --env-file .env --profile minio up -d
-```
-
-4. Verify:
-
-```zsh title="Terminal"
-curl -sS http://localhost:3081/ # API — returns JSON
-curl -I http://localhost:3082/ # Dashboard — returns 200
-```
-
-- Dashboard: http://localhost:3082
-- API: http://localhost:3081
-- MinIO Console: http://localhost:9001 (log in with `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` from your `.env`)
-
-## Build from Source
-
-Use this if you want to build the images locally (e.g. to run code changes).
-
-### Prerequisites
-
-- Docker Desktop installed and running
-- `git` installed
-- ~8 GB RAM available for the dashboard build
-
-### Steps
-
-1. Clone the repository:
-
-```zsh title="Terminal"
-git clone https://github.com/classroomio/classroomio.git
-cd classroomio
-```
-
-2. Copy the env template and edit it:
-
-```zsh title="Terminal"
-cp .env.example .env
-```
-
-Open `.env` and set `DASHBOARD_ORIGIN` for your domain. For local development the defaults work as-is — the startup script generates `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` when they are missing, and randomizes the MinIO credentials.
-
-3. Build and start the full stack:
-
-```zsh title="Terminal"
-./run-docker-full-stack.sh --build
-```
-
-The script builds and starts Postgres, Redis, MinIO (object storage), the API, the dashboard, and the background **jobs** worker.
-
-:::note[Default is pull, not build]
-Run without `--build` and the script **pulls** the published `classroomio/*` images at the
-`CIO_VERSION` in your `.env` instead of building locally — that's the faster default path.
-:::
-
-## One `.env`, One Command
-
-Docker self-hosting uses a single root `.env` file. Every `docker compose` command reads it via `--env-file .env` — you never need separate env files per service. See [`.env.example`](https://github.com/classroomio/classroomio/blob/main/.env.example) for the full template.
-
-## Configuration
-
-Every variable the stack reads is documented once in the shared
-[Configuration](/self-hosted/configuration) section — set the ones you need in your root `.env`:
+Every variable the stack reads is documented in the shared
+[Configuration](/self-hosted/configuration) section:
@@ -130,51 +157,15 @@ Every variable the stack reads is documented once in the shared
Google OAuth, Unsplash, OpenAI, branding.
-
- `LICENSE_KEY` for Enterprise features.
+
+ `LICENSE_KEY` for SSO, token auth, and no-tracking.
## Upgrading
-ClassroomIO publishes versioned images and `CIO_VERSION` controls which tag you run. To upgrade the
-pre-built images path, bump `CIO_VERSION` and re-pull:
+Run `./classroomio.sh upgrade`: it backs up Postgres and MinIO first, then pulls the images at
+your (possibly newly pinned) `CIO_VERSION` and restarts. Rollback = set `CIO_VERSION` back and
+upgrade again. Details: [Versions & upgrades](/self-hosted/configuration/versions).
-```zsh title="Terminal"
-docker compose -f docker-compose.images.yaml --env-file .env --profile minio pull
-docker compose -f docker-compose.images.yaml --env-file .env --profile minio up -d
-```
-
-For the build-from-source path, `git pull` and re-run `./run-docker-full-stack.sh --build`. See
-[Configuration → Versions & upgrades](/self-hosted/configuration/versions) for pinning, rollback, and
-the pre-upgrade backup steps.
-
-## Common Commands
-
-```zsh title="Terminal"
-# Start the full stack (pulls pre-built images by default)
-./run-docker-full-stack.sh
-
-# Build from source instead of pulling
-./run-docker-full-stack.sh --build
-
-# Start without MinIO (requires an external S3-compatible store)
-./run-docker-full-stack.sh --no-minio
-```
-
-See [docker/docs/SELF_HOST.md](https://github.com/classroomio/classroomio/blob/main/docker/docs/SELF_HOST.md) for the full Docker reference including troubleshooting.
-
-## Troubleshooting: "No space left on device"
-
-```zsh title="Terminal"
-docker system df # check usage
-docker builder prune -f # free build cache
-docker image prune -a -f # free unused images
-docker container prune -f # free stopped containers
-```
-
-:::warning[Volume prune deletes data]
-Avoid `docker volume prune` after `docker compose ... down` — it can remove your database and storage volumes.
-:::
-
-Then restart: `./run-docker-full-stack.sh`
+Something not working? See [Troubleshooting](/self-hosted/troubleshooting).
diff --git a/apps/docs/content/docs/self-hosted/dokploy.mdx b/apps/docs/content/docs/self-hosted/dokploy.mdx
index e3c9182b..1e465827 100644
--- a/apps/docs/content/docs/self-hosted/dokploy.mdx
+++ b/apps/docs/content/docs/self-hosted/dokploy.mdx
@@ -3,10 +3,10 @@ title: Dokploy
description: Deploy ClassroomIO on Dokploy using the pull-based Docker Compose stack.
---
-[Dokploy](https://dokploy.com) is an open-source, self-hosted PaaS — a Heroku/Vercel alternative you run
-on your own VPS. It deploys ClassroomIO from the published `classroomio/*` images using the **same
+[Dokploy](https://dokploy.com) is an open-source, self-hosted PaaS (a Heroku/Vercel alternative you run
+on your own VPS). It deploys ClassroomIO from the published `classroomio/*` images using the **same
pull-based Docker Compose file** as the [Docker](/self-hosted/docker) and [Coolify](/self-hosted/coolify)
-guides — Dokploy **pulls** the images and never builds anything.
+guides. Dokploy **pulls** the images and never builds anything.
## Watch the walkthrough
@@ -17,18 +17,18 @@ steps below mirror it.
## Prerequisites
-- A running **Dokploy instance** with a **server connected** to it. Dokploy runs on any VPS —
+- A running **Dokploy instance** with a **server connected** to it. Dokploy runs on any VPS:
DigitalOcean, Hetzner, AWS (Lightsail/EC2), or another provider. See Dokploy's
[installation guide](https://docs.dokploy.com/docs/core/installation) to set this up.
- A server sized for ClassroomIO. Dokploy's own baseline stack needs ~2 GB RAM, and ClassroomIO's
- seven services need **8 GB for reliable operation** — provision for both. See
+ seven services need **8 GB for reliable operation**, so provision for both. See
[Server requirements](/self-hosted#server-requirements).
- A **domain** with a DNS **A record pointing at your server's public IP**. Dokploy's built-in Traefik
proxy terminates HTTPS and issues the certificate.
-- An **SMTP provider** for signup verification, password reset, and invite emails — see
+- An **SMTP provider** for signup verification, password reset, and invite emails. See
[Configuration → Email (SMTP)](/self-hosted/configuration/email).
- *Optional:* an **OpenAI API key** (transcription + AI course generation) and Google OAuth / Unsplash
- keys — see [Configuration → Integrations](/self-hosted/configuration/integrations).
+ keys. See [Configuration → Integrations](/self-hosted/configuration/integrations).
:::note[Two secrets to generate first]
Dokploy does **not** run our secret-generation script, so generate `BETTER_AUTH_SECRET` and
@@ -64,7 +64,7 @@ In the Dokploy dashboard, open **Projects** and click **Create Project**. Give i
### Add a Compose service
Inside the project, click **Create Service** and choose **Compose** from the service types. When
-prompted, **select the server** you connected to Dokploy — this is the VPS ClassroomIO runs on.
+prompted, **select the server** you connected to Dokploy. This is the VPS ClassroomIO runs on.

@@ -72,10 +72,10 @@ prompted, **select the server** you connected to Dokploy — this is the VPS Cla
### Choose the Raw (manual) provider and paste the compose file
-Dokploy asks where the compose file comes from — GitHub, GitLab, Bitbucket, or **Raw**. Choose **Raw**
+Dokploy asks where the compose file comes from: GitHub, GitLab, Bitbucket, or **Raw**. Choose **Raw**
(the manual option) and paste the contents of
-[`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml)
-— the same pull-based stack used for the [Docker](/self-hosted/docker) and [Coolify](/self-hosted/coolify)
+[`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml),
+the same pull-based stack used for the [Docker](/self-hosted/docker) and [Coolify](/self-hosted/coolify)
deployments.

@@ -101,7 +101,7 @@ SMTP_PASSWORD=
SMTP_SENDER=ClassroomIO
```
-The full variable reference — object storage, CSP, integrations, licensing, versions — lives in the
+The full variable reference (object storage, CSP, integrations, licensing, versions) lives in the
shared [Configuration](/self-hosted/configuration) section. Copy what you need before deploying.

@@ -111,7 +111,7 @@ shared [Configuration](/self-hosted/configuration) section. Copy what you need b
### Point your domain at the server
Before the domain will work, add a DNS **A record** for it (e.g. `dash.yourdomain.com`) pointing at
-your server's public IP. Do this for the dashboard domain — and any other domain you plan to use (such
+your server's public IP. Do this for the dashboard domain, and any other domain you plan to use (such
as storage). DNS can take a few minutes to propagate.
@@ -121,7 +121,7 @@ as storage). DNS can take a few minutes to propagate.
Click **Deploy**. Dokploy pulls `classroomio/{api,dashboard,jobs}` plus Postgres, Redis, and MinIO,
then starts the stack. The API runs database setup on first boot. Watch the **logs** until every
-service is up — this is your first deploy.
+service is up. This is your first deploy.

@@ -131,12 +131,12 @@ service is up — this is your first deploy.
Open the service's **Domains** section and click **Add Domain**. In the modal that appears:
-- **Service:** select **dashboard** — the service this domain routes to.
+- **Service:** select **dashboard**, the service this domain routes to.
- **Host:** your dashboard domain, e.g. `dash.yourdomain.com`.
-- **Container Port:** `3082` — the port the dashboard listens on.
+- **Container Port:** `3082`, the port the dashboard listens on.
- **HTTPS:** turn it on, then choose **Let's Encrypt** as the certificate provider.
-Create the domain. Dokploy provisions the certificate through Traefik — it usually only takes a moment.
+Create the domain. Dokploy provisions the certificate through Traefik, which usually only takes a moment.

@@ -174,7 +174,7 @@ shared [Configuration](/self-hosted/configuration) section:
Google OAuth, Unsplash, OpenAI.
-
+
`LICENSE_KEY` for Enterprise features.
diff --git a/apps/docs/content/docs/self-hosted/enterprise.mdx b/apps/docs/content/docs/self-hosted/enterprise.mdx
new file mode 100644
index 00000000..8974f7b8
--- /dev/null
+++ b/apps/docs/content/docs/self-hosted/enterprise.mdx
@@ -0,0 +1,57 @@
+---
+title: Enterprise & licensing
+description: What a ClassroomIO enterprise license unlocks on self-hosted — SSO, token auth, no-tracking — and how LICENSE_KEY validation works.
+---
+
+ClassroomIO is [open core](/self-hosted/101#how-licensing-works): the complete LMS is free to
+self-host, and a small set of enterprise features is unlocked by a paid license key. No key means
+everything else still works, so leave `LICENSE_KEY` unset until you need one of these.
+
+## What a license unlocks
+
+| Feature | What it does |
+|---------|--------------|
+| **SSO** | Single Sign-On with OIDC (Okta, Azure AD, and more). Setup: [Enterprise SSO](/enterprise-sso). |
+| **Token auth** | Issue signed tokens to log users in from your own app. Setup: [Token Auth](/token-auth). |
+| **No-tracking** | Disables product analytics in the dashboard (PostHog, Umami) and third-party embeds. Without this feature, self-hosted mode already disables error reporting and the feedback widget, but not product analytics. |
+
+Without a valid license, the SSO and token-auth APIs return `403 FEATURE_REQUIRES_LICENSE` and
+their settings are hidden in the dashboard UI.
+
+## Setting `LICENSE_KEY`
+
+`LICENSE_KEY` is read by the **api** service. Set it wherever that service takes its environment:
+
+| Deployment | Where to set it |
+|------------|-----------------|
+| Docker | repository root `.env` |
+| Railway | the `cio-api` service environment |
+| Coolify / Dokploy | the resource's environment variables |
+| Local development | `apps/api/.env` |
+
+```zsh title=".env"
+LICENSE_KEY=
+```
+
+Restart the stack with `./classroomio.sh restart` after setting it (this recreates the
+`api` container so it picks up the new value — `docker compose restart api` on its own
+does **not**, since it doesn't recreate the container).
+
+## How validation works
+
+The api service validates the key against ClassroomIO's license server
+(`enterprise-api.classroomio.dev`) and caches the result in memory for **one hour**.
+
+What that means operationally:
+
+- **The api container needs outbound HTTPS** to that host. In an air-gapped or egress-restricted
+ network, licensed features can't activate. Contact ClassroomIO about your environment before
+ buying.
+- If a check fails (network blip, license server unreachable), licensed features switch off and
+ the failure is also cached, so it can take **up to an hour** (or an api restart) after
+ connectivity returns before they come back.
+- Everything outside the licensed feature list is unaffected by license-server reachability.
+
+## Getting a license
+
+[Book a call](https://cal.com/classroomio/enterprise) to get an enterprise license key.
diff --git a/apps/docs/content/docs/self-hosted/index.mdx b/apps/docs/content/docs/self-hosted/index.mdx
index e1026461..e5a655d7 100644
--- a/apps/docs/content/docs/self-hosted/index.mdx
+++ b/apps/docs/content/docs/self-hosted/index.mdx
@@ -1,10 +1,12 @@
---
title: Self Hosting
description: Run ClassroomIO on your own infrastructure — with Docker, DigitalOcean, Railway, Coolify, or Dokploy.
+sidebar:
+ label: Overview
---
ClassroomIO is open source, and you can run the whole platform on your own infrastructure. Every
-option below runs the same stack — the API, the dashboard, and the background jobs worker, backed by
+option below runs the same stack: the API, the dashboard, and the background jobs worker, backed by
Postgres and Redis.
Pick the path that matches where you want it to run.
@@ -14,7 +16,7 @@ Pick the path that matches where you want it to run.
Run anywhere Docker runs. Pull the pre-built images, or build from source.
- A plain VPS, end to end — Docker Compose plus Caddy for automatic HTTPS.
+ A plain VPS, end to end: Docker Compose plus Caddy for automatic HTTPS.
Deploy to Railway from a template. The quickest managed option.
@@ -49,16 +51,18 @@ pick, have mail credentials ready before you start.
- Object storage (**MinIO** or S3-compatible) if you want file and video uploads.
- *Optional:* an **OpenAI API key** for transcription and AI course generation.
-For the full environment variable reference — secrets, email, storage, CSP, integrations, licensing,
-and versions — see the [Configuration](/self-hosted/configuration) section, which documents every
-variable the stack reads.
+The [Configuration](/self-hosted/configuration) section has the full environment variable reference
+(secrets, email, storage, CSP, integrations, licensing, versions) and documents every variable the
+stack reads.
## Server requirements
-ClassroomIO is not a single container — it runs **seven services**: Postgres, Redis, MinIO (object
-storage), the API, the dashboard, and a background **jobs worker** that handles video processing,
-transcription, and AI course generation. The jobs worker spikes RAM whenever it runs, so a server's
-idle memory use understates what you actually need.
+ClassroomIO is not a single container. It runs **seven services**: Postgres, Redis, MinIO (object
+storage), the API, the dashboard, a background **jobs worker** that handles video processing,
+transcription, and AI course generation, and **`minio-init`**, a one-shot container that creates
+the MinIO buckets on first start and then exits, so it won't show up in `docker ps` afterward. The
+jobs worker spikes RAM whenever it runs, so a server's idle memory use understates what you
+actually need.
| Resource | Minimum | Recommended |
| -------- | ---------------- | ------------- |
@@ -67,31 +71,30 @@ idle memory use understates what you actually need.
| Disk | 20 GB + uploads | 40 GB+ SSD |
:::warning[Don't under-provision RAM]
-A **1 GB server cannot run ClassroomIO at all** — it exhausts memory on the base stack before the
-app is even up. **2 GB is only enough for a PaaS control plane** (Coolify, Dokploy) with little left
-over for the app. Target **8 GB for reliable operation**, especially once the jobs worker starts
-processing video or AI generation.
+A **1 GB** server can't run ClassroomIO at all: it exhausts memory on the base stack before the app
+is even up. **2 GB** is only enough for a PaaS control plane (Coolify, Dokploy), with little left
+over for the app itself. Aim for **8 GB** for reliable operation, especially once the jobs worker
+starts processing video or AI generation.
:::
:::note[Running a PaaS on the same box?]
-Coolify and Dokploy need roughly **2 GB for themselves** on top of ClassroomIO's requirement. If
-they share the server, size it for both — e.g. 8 GB for ClassroomIO plus the PaaS overhead.
+Coolify and Dokploy need roughly **2 GB** for themselves on top of ClassroomIO's requirement. If
+they share the server, size it for both, e.g. 8 GB for ClassroomIO plus the PaaS overhead.
:::
-Building the images **from source** is a separate, heavier job that needs ~8 GB RAM at build time —
-see the [Docker guide](/self-hosted/docker#build-from-source). Pulling the pre-built images (the
-default) avoids that.
+Building the images **from source** is a separate, heavier job that needs ~8 GB RAM at build time.
+See the [Docker guide](/self-hosted/docker). Pulling the pre-built images (the default) avoids that.
## HTTPS is required in production
:::warning[Serve over HTTPS on any real domain]
ClassroomIO's authentication session cookies are marked `Secure`, so browsers refuse to store or
-send them over plain HTTP — **login silently fails**. Plain HTTP works only for local evaluation on
+send them over plain HTTP. **Login silently fails**. Plain HTTP works only for local evaluation on
`localhost`. Any internet-facing deployment **must** be served over HTTPS.
:::
Terminate TLS at a reverse proxy in front of the stack:
-- **Railway** and **Coolify** issue the certificate and terminate HTTPS for you — nothing extra to do.
+- **Railway** and **Coolify** issue the certificate and terminate HTTPS for you: nothing extra to do.
- **Raw Docker** on your own server: put **nginx**, **Caddy**, or **Traefik** in front, and set
`DASHBOARD_ORIGIN` / `ORIGIN` to the `https://` URL your users visit.
diff --git a/apps/docs/content/docs/self-hosted/railway.mdx b/apps/docs/content/docs/self-hosted/railway.mdx
index 3dfd2414..b6d35a68 100644
--- a/apps/docs/content/docs/self-hosted/railway.mdx
+++ b/apps/docs/content/docs/self-hosted/railway.mdx
@@ -15,7 +15,7 @@ Prefer to follow along? This video walks through the whole deployment end to end
## Prerequisites
-- A **Railway account** (a paid plan is recommended — the stack runs several always-on services).
+- A **Railway account** (a paid plan is recommended, since the stack runs several always-on services).
- An **SMTP provider** (or Zoho/ZeptoMail) for signup verification, password reset, and invite emails. See [SMTP Setup](#smtp-setup).
- *Optional:* a **custom domain** for the dashboard and storage, an **OpenAI API key** (transcription + AI course generation), and Google OAuth / Unsplash keys.
@@ -32,7 +32,7 @@ See [Configuration → Environment & secrets](/self-hosted/configuration/secrets
## Step-by-step walkthrough
-The one-click template provisions **all of the services** for you — Postgres, Redis, `cio-api`, `cio-dashboard`, `cio-jobs`, and `cio-minio` (object storage). The flow is: **configure a few values → deploy → connect the dashboard domain → sign up**.
+The one-click template provisions **all of the services** for you: Postgres, Redis, `cio-api`, `cio-dashboard`, `cio-jobs`, and `cio-minio` (object storage). The flow is: **configure a few values → deploy → connect the dashboard domain → sign up**.
@@ -49,10 +49,10 @@ Click the **Deploy on Railway** button above. Railway opens the template page wh
### Review the preconfigured services
-The template comes with every service preconfigured — most need nothing from you:
+The template comes with every service preconfigured: most need nothing from you:
-- **Redis** and **Postgres** — no configuration required.
-- **cio-dashboard** — no required configuration (optionally set branding such as the public OG image; not required).
+- **Redis** and **Postgres**: no configuration required.
+- **cio-dashboard**: no required configuration (optionally set branding such as the public OG image; not required).
The next three steps cover the services that **do** need input: `cio-jobs`, `cio-minio`, and `cio-api`.
@@ -78,7 +78,7 @@ The next three steps cover the services that **do** need input: `cio-jobs`, `cio
Set the storage credentials on `cio-minio`:
-- `MINIO_SERVER_URL` — optional but recommended (fixes MinIO console redirects behind the proxy); the template presets it to the generated `cio-minio` domain, so you normally just confirm it
+- `MINIO_SERVER_URL`: optional but recommended (fixes MinIO console redirects behind the proxy); the template presets it to the generated `cio-minio` domain, so you normally just confirm it
- `MINIO_ROOT_USER`
- `MINIO_ROOT_PASSWORD`
@@ -97,16 +97,16 @@ On `cio-api`, set the SMTP host and any additional values you want. See the [Env
### Deploy
-Click **Deploy**. Railway begins building and bringing the services online — Redis, Postgres, and the rest come up first.
+Click **Deploy**. Railway begins building and bringing the services online: Redis, Postgres, and the rest come up first.

-### First boot may crash — this is expected
+### First boot may crash: this is expected
:::warning[Only if you use the Railway-generated subdomain]
-On the first deploy, `cio-api` can crash. This is expected **only when you rely on the Railway-generated subdomain**: the API needs the dashboard's public domain (Better Auth's base URL), which doesn't exist yet. If you're using your **own custom subdomain**, this crash doesn't happen — skip ahead to signing up.
+On the first deploy, `cio-api` can crash. This is expected **only when you rely on the Railway-generated subdomain**: the API needs the dashboard's public domain (Better Auth's base URL), which doesn't exist yet. If you're using your **own custom subdomain**, this crash doesn't happen. Skip ahead to signing up.
:::

@@ -141,7 +141,7 @@ Open the generated dashboard URL in a **new browser tab** and **sign up** to cre
## Environment variable reference
-You set these values **while configuring each service on the template page** (the configure steps above) — you don't enter them again after deploying. Use this section as the reference for what each service's variables should be.
+You set these values **while configuring each service on the template page** (the configure steps above); you don't enter them again after deploying. Use this section as the reference for what each service's variables should be.
### cio-api
@@ -198,7 +198,7 @@ Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL
### cio-jobs (background worker)
-The jobs worker has **no public domain** — it drains the Redis queue (video processing, transcription, AI course generation, and emails). It shares the same Postgres, Redis, storage, email, and OpenAI settings as the API:
+The jobs worker has **no public domain**: it drains the Redis queue (video processing, transcription, AI course generation, and emails). It shares the same Postgres, Redis, storage, email, and OpenAI settings as the API:
```zsh title="env"
DATABASE_URL="${{db.DATABASE_URL}}"
@@ -225,7 +225,7 @@ OPENAI_API_KEY=
### cio-minio (object storage)
-`cio-minio` ships **in the template** — its `/data` volume is already attached and the `videos`,
+`cio-minio` ships **in the template**: its `/data` volume is already attached and the `videos`,
`documents`, and `media` buckets are already seeded. You only set its credentials during
configuration:
@@ -239,11 +239,11 @@ MINIO_SERVER_URL=https://${{cio-minio.RAILWAY_PUBLIC_DOMAIN}}
The api and jobs services reach MinIO over **two** endpoints: the private `OBJECT_STORAGE_ENDPOINT`
(server uploads/reads) and the public `OBJECT_STORAGE_PUBLIC_ENDPOINT` + `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`
-(browser-facing presigned downloads and public images) — already wired in the env blocks above.
+(browser-facing presigned downloads and public images), already wired in the env blocks above.
`OBJECT_STORAGE_FORCE_PATH_STYLE=true` is required for MinIO.
:::warning[MinIO is your durability boundary]
-Single-node MinIO has no redundancy — the Railway Volume is the only thing protecting your uploads, so
+Single-node MinIO has no redundancy: the Railway Volume is the only thing protecting your uploads, so
back it up. See [Configuration → Object storage & uploads](/self-hosted/configuration/storage) for
external Cloudflare R2 / AWS S3 alternatives.
:::
@@ -254,7 +254,7 @@ See [Configuration → Email (SMTP)](/self-hosted/configuration/email) for the S
### SSO (Enterprise)
-SSO requires a `LICENSE_KEY` on the `cio-api` service. See [Configuration → SSO & licensing](/self-hosted/configuration/sso).
+SSO requires a `LICENSE_KEY` on the `cio-api` service. See [Enterprise & licensing](/self-hosted/enterprise).
## Upgrades
diff --git a/apps/docs/content/docs/self-hosted/troubleshooting.mdx b/apps/docs/content/docs/self-hosted/troubleshooting.mdx
new file mode 100644
index 00000000..ba02dd5c
--- /dev/null
+++ b/apps/docs/content/docs/self-hosted/troubleshooting.mdx
@@ -0,0 +1,128 @@
+---
+title: Troubleshooting
+description: Diagnose common self-hosted ClassroomIO failures — sign-in errors, stuck uploads, out-of-memory kills, and disk issues.
+---
+
+Start here when something misbehaves. Every entry below is a failure mode we've seen in real
+deployments, with its cause and fix.
+
+First, always check what's actually running:
+
+```zsh title="Terminal"
+./classroomio.sh logs # follow all service logs
+docker ps # anything restarting or exited?
+```
+
+## Quick reference
+
+| Symptom | Likely cause | Fix |
+|---------|-------------|-----|
+| Sign-in fails right after startup with a database error (`57P03`) | Postgres is still starting | Wait 30–60 s and retry, [details](#sign-in-fails-with-57p03-right-after-startup) |
+| Login silently fails on your domain (no error, just back to the login page) | Serving over plain HTTP — session cookies are `Secure` | Serve over HTTPS — [details](/self-hosted#https-is-required-in-production) |
+| Login loops or CSRF errors | `DASHBOARD_ORIGIN` doesn't match the URL users actually visit | Set it to the exact `https://` origin, restart |
+| Uploads stuck at "processing" forever, no error | The `jobs` worker isn't running, or Redis is unreachable | [details](#uploads-stuck-at-processing) |
+| Containers randomly killed, stack unstable | Out of memory — a transcode can exhaust a 4 GB server | [details](#containers-are-oom-killed) |
+| `No space left on device`, MinIO "minimum free drive threshold" | Disk full (images, build cache, uploads) | [details](#no-space-left-on-device) |
+| API container exits immediately at boot | Database migration failed | [details](#api-container-exits-at-boot) |
+| No signup / invite / reset emails, logs show `ECONNREFUSED` | SMTP not configured or wrong host/port | Check [Email settings](/self-hosted/configuration/email) |
+| Videos never get captions; no transcription | `OPENAI_API_KEY` not set | [details](#transcription-never-happens) |
+| Crash loops with `read ECONNABORTED` during managed-Redis maintenance | A bug fixed upstream | Upgrade — [details](#crash-loops-during-redis-maintenance) |
+
+## Sign-in fails with 57P03 right after startup
+
+**Symptom:** immediately after starting the stack, sign-in returns a database error with Postgres
+code `57P03` ("the database system is in recovery mode" / "is starting up").
+
+**Cause:** Postgres reports itself ready (`pg_isready` passes) before it has finished crash
+recovery, so the API can connect but queries fail.
+
+**Fix:** wait 30–60 seconds and retry. Recovery completes on its own. Current compose files
+already guard against this: the Postgres healthcheck requires a successful `SELECT 1`, not just
+`pg_isready`, so dependent services wait until queries actually work. If you see this repeatedly,
+your compose file predates that healthcheck. Refresh it:
+
+```zsh title="Terminal"
+curl -fsSLO https://raw.githubusercontent.com/classroomio/classroomio/main/docker-compose.images.yaml
+./classroomio.sh restart
+```
+
+## Uploads stuck at "processing"
+
+Video processing, transcription, AI course generation, and most emails don't run in the API. They
+are queued in Redis and executed by the separate **jobs** worker. If that worker is down (or the API
+has no `REDIS_URL`), jobs are created but nothing ever picks them up: uploads sit at "processing"
+with no error anywhere in the UI.
+
+```zsh title="Terminal"
+docker ps | grep jobs # is cio-jobs up?
+./classroomio.sh logs jobs # any errors?
+```
+
+The bundled compose file always starts the worker; on PaaS platforms make sure the jobs service
+wasn't scaled to zero or crashed on boot.
+
+## Containers are OOM-killed
+
+The jobs worker spikes RAM while processing video, enough to get Postgres or other containers
+killed on a 4 GB server even though the stack idles comfortably. See
+[server requirements](/self-hosted#server-requirements).
+
+- Confirm with `docker inspect | grep -i oom` and `free -h`.
+- Add swap as a buffer, or resize to 8 GB for reliable operation.
+
+## "No space left on device"
+
+Also surfaces as MinIO's "Storage reached its minimum free drive threshold".
+
+```zsh title="Terminal"
+df -h # check host disk
+docker system df # check Docker usage
+docker builder prune -f # free build cache
+docker image prune -a -f # free unused images
+docker container prune -f # free stopped containers
+```
+
+Then restart: `./classroomio.sh start`
+
+:::danger[Never use `docker volume prune` or `down -v`]
+`docker volume prune` removes volumes not attached to a running container: run after
+`docker compose down`, it deletes your **database, Redis, and uploads** (`postgres-data`,
+`redis-data`, `minio-data`). `docker compose down -v` does the same explicitly. Use only the prune
+commands above to free space.
+:::
+
+## API container exits at boot
+
+The API runs database migrations before it starts listening. If a migration fails, the container
+exits rather than serve a half-migrated database. That's deliberate.
+
+```zsh title="Terminal"
+./classroomio.sh logs api # the migration error is at the end
+```
+
+Common causes: wrong `DATABASE_URL`/`POSTGRES_*` credentials after an edit, or Postgres not yet
+healthy. Fix the cause and `./classroomio.sh start` again. Migrations are safe to re-run.
+
+## Transcription never happens
+
+Transcription (video captions) requires `OPENAI_API_KEY` on the **jobs** service. Without it, media
+jobs still complete (thumbnails and processing work), but the transcription step is skipped with a
+warning in the jobs logs. Set the key and re-trigger transcription; see
+[Integrations](/self-hosted/configuration/integrations).
+
+## Crash loops during Redis maintenance
+
+Older versions could crash-loop with cascading `read ECONNABORTED` errors when a managed Redis
+provider (e.g. Redis Cloud) ran scheduled maintenance or failover. This was fixed upstream: the
+stack now reconnects and resumes automatically. If you hit it, upgrade:
+
+```zsh title="Terminal"
+./classroomio.sh upgrade
+```
+
+## Platform-specific issues
+
+Deploying on a DigitalOcean-style VPS with Caddy? That guide has its own
+[troubleshooting table](/self-hosted/digitalocean#troubleshooting) covering certificates, firewalls,
+and proxy issues. For anything else, [open an issue](https://github.com/classroomio/classroomio/issues)
+and include the output of `./classroomio.sh logs`.
diff --git a/apps/docs/content/docs/token-auth.mdx b/apps/docs/content/docs/token-auth.mdx
index 7683fcf3..285e910f 100644
--- a/apps/docs/content/docs/token-auth.mdx
+++ b/apps/docs/content/docs/token-auth.mdx
@@ -12,7 +12,7 @@ Token Auth requires an **Enterprise** plan.
**Cloud:** [Contact us](mailto:support@classroomio.com) to upgrade.
-**Self-hosted:** Requires an Enterprise license key. Set `LICENSE_KEY` in the API service environment — see [Self Hosting → SSO & licensing](/self-hosted/configuration/sso).
+**Self-hosted:** Requires an Enterprise license key. Set `LICENSE_KEY` in the API service environment — see [Self Hosting → Enterprise & licensing](/self-hosted/enterprise).
:::
## How It Works
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 492194c1..74b35bda 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -17,7 +17,7 @@
"@tailwindcss/typography": "0.5.19",
"@tailwindcss/vite": "4.1.18",
"astro": "7.0.9",
- "blume": "1.1.2",
+ "blume": "1.2.0",
"mermaid": "^11.16.0",
"tailwindcss": "4.1.18"
},
diff --git a/apps/docs/scripts/package-assets.mjs b/apps/docs/scripts/package-assets.mjs
index 562aba1e..99674da6 100644
--- a/apps/docs/scripts/package-assets.mjs
+++ b/apps/docs/scripts/package-assets.mjs
@@ -45,6 +45,8 @@ const REDIRECTS = `# Generated by scripts/package-assets.mjs — do not edit.
/docs/contributor-guides /docs/guides/contributing 301
/docs/contributor-guides/* /docs/guides/contributing/:splat 301
+
+/docs/self-hosted/configuration/sso /docs/self-hosted/enterprise 301
${GUIDE_REDIRECTS}
`;
diff --git a/apps/website/src/routes/(redirects)/classroomio.sh/+page.server.ts b/apps/website/src/routes/(redirects)/classroomio.sh/+page.server.ts
new file mode 100644
index 00000000..ef523854
--- /dev/null
+++ b/apps/website/src/routes/(redirects)/classroomio.sh/+page.server.ts
@@ -0,0 +1,13 @@
+import { redirect } from '@sveltejs/kit';
+import { client } from '$lib/utils/posthog';
+
+export const prerender = false;
+
+export const load = ({ request }) => {
+ client.capture({
+ distinctId: request.headers.get('x-forwarded-for') || new Date().getTime().toString(),
+ event: 'classroomio.sh downloaded'
+ });
+
+ redirect(307, 'https://raw.githubusercontent.com/classroomio/classroomio/main/classroomio.sh');
+};
diff --git a/classroomio.sh b/classroomio.sh
new file mode 100755
index 00000000..450c92a7
--- /dev/null
+++ b/classroomio.sh
@@ -0,0 +1,666 @@
+#!/usr/bin/env bash
+#
+# ClassroomIO self-host lifecycle manager.
+#
+# Run with no arguments for an interactive menu, or pass a subcommand directly:
+#
+# ./classroomio.sh # interactive menu
+# ./classroomio.sh install # first-time setup: fetch files, create .env, start
+# ./classroomio.sh start # non-interactive start (env auto-setup + up -d)
+# ./classroomio.sh stop|restart|upgrade|logs|backup
+#
+# The script is standalone: run it in an empty directory and `install` downloads
+# docker-compose.images.yaml + .env.example from GitHub. Inside a repo checkout it
+# uses the local files. It never overwrites an existing .env or compose file.
+#
+# The compose file tracks `main` (the stack topology); CIO_VERSION in .env pins the
+# app images. Pin an exact release (e.g. 1.4.2) in production.
+
+set -euo pipefail
+
+ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_NAME="classroomio"
+ENV_FILE="${ROOT_DIR}/.env"
+ENV_EXAMPLE_FILE="${ROOT_DIR}/.env.example"
+BUILD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.yaml"
+IMAGES_COMPOSE_FILE="${ROOT_DIR}/docker-compose.images.yaml"
+RAW_BASE_URL="https://raw.githubusercontent.com/classroomio/classroomio/main"
+
+# Default to PULLING pre-built images (fast). Pass --build to build from source.
+USE_IMAGES=true
+COMPOSE_FILE="${IMAGES_COMPOSE_FILE}"
+# --profile flags live in ONE variable so every command (start, stop, logs, backup)
+# sees the same profile state. --no-minio empties it.
+PROFILE_ARGS="--profile minio"
+
+print_usage() {
+ cat <<'USAGE'
+Usage: ./classroomio.sh [options] [command] [args]
+
+Commands:
+ install First-time setup: fetch compose file + .env.example (if missing),
+ create .env, generate secrets, pull images and start the stack.
+ start Start the stack (auto-generates missing secrets first).
+ stop Stop all containers (data volumes are kept).
+ restart Restart all containers.
+ upgrade Back up first, then pull newer images and restart.
+ logs [svc] Follow logs (all services, or one: api, dashboard, jobs, postgres, ...).
+ backup Dump Postgres and archive the MinIO volume into ./backups/.
+
+Run without a command to get an interactive menu.
+
+Options:
+ --build Use docker-compose.yaml and build images from source instead of pulling.
+ Requires a local repo checkout (git clone) — not available in a
+ standalone directory set up via 'install'.
+ --no-minio Exclude the bundled MinIO (requires external S3-compatible storage).
+ -h, --help Show this help message.
+USAGE
+}
+
+# ──────────────────────────────────────────────
+# Compose CLI detection: `docker compose` (v2) or legacy `docker-compose`.
+# ──────────────────────────────────────────────
+
+COMPOSE_CMD=""
+
+detect_compose() {
+ if ! command -v docker >/dev/null 2>&1; then
+ echo "Error: docker is not installed or not on PATH."
+ exit 1
+ fi
+ if docker compose version >/dev/null 2>&1; then
+ COMPOSE_CMD="docker compose"
+ elif command -v docker-compose >/dev/null 2>&1; then
+ COMPOSE_CMD="docker-compose"
+ else
+ echo "Error: neither 'docker compose' nor 'docker-compose' is available."
+ exit 1
+ fi
+}
+
+# All compose invocations go through this so the env file, project name, compose
+# file, and profiles are consistent everywhere. Word-splitting of COMPOSE_CMD and
+# PROFILE_ARGS is intentional.
+compose() {
+ # shellcheck disable=SC2086
+ ${COMPOSE_CMD} --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${PROFILE_ARGS} "$@"
+}
+
+# ──────────────────────────────────────────────
+# .env helpers
+# ──────────────────────────────────────────────
+
+get_env_value() {
+ local key="$1"
+ local line
+ line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)"
+ printf '%s' "${line#*=}"
+}
+
+upsert_env_value() {
+ local key="$1"
+ local value="$2"
+ local tmp_file
+
+ tmp_file="$(mktemp)"
+ awk -v key="${key}" -v value="${value}" '
+ BEGIN { updated = 0 }
+ $0 ~ ("^" key "=") {
+ if (updated == 0) {
+ print key "=" value;
+ updated = 1;
+ }
+ next;
+ }
+ { print }
+ END {
+ if (updated == 0) {
+ print key "=" value;
+ }
+ }
+ ' "${ENV_FILE}" > "${tmp_file}"
+
+ mv "${tmp_file}" "${ENV_FILE}"
+}
+
+is_insecure_token_value() {
+ local value="$1"
+ local normalized
+
+ normalized="$(printf '%s' "${value}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
+
+ if [[ "${normalized}" =~ ^\"(.*)\"$ ]]; then
+ normalized="${BASH_REMATCH[1]}"
+ elif [[ "${normalized}" =~ ^\'(.*)\'$ ]]; then
+ normalized="${BASH_REMATCH[1]}"
+ fi
+
+ case "${normalized}" in
+ "" | "replace-with-a-long-random-token" | "local-dev-api-key" | "changeme" | "replace-me")
+ return 0
+ ;;
+ replace-with-* | *local-dev* | *change-this* | *your-*)
+ return 0
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+generate_secure_token() {
+ if command -v openssl >/dev/null 2>&1; then
+ openssl rand -hex 32
+ return
+ fi
+
+ if command -v node >/dev/null 2>&1; then
+ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
+ return
+ fi
+
+ if command -v python3 >/dev/null 2>&1; then
+ python3 -c "import secrets; print(secrets.token_hex(32))"
+ return
+ fi
+
+ if [[ -r /dev/urandom ]]; then
+ head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'
+ echo
+ return
+ fi
+
+ # Last resort only: no openssl, node, python3, or /dev/urandom. A timestamp-derived
+ # value is weak (guessable within the install's time window) but better than failing.
+ date +%s%N | shasum | awk '{ print $1 }'
+}
+
+ensure_secure_auth_tokens() {
+ if [[ ! -f "${ENV_FILE}" ]]; then
+ touch "${ENV_FILE}"
+ fi
+
+ local private_server_key
+ private_server_key="$(get_env_value PRIVATE_SERVER_KEY)"
+
+ # PRIVATE_SERVER_KEY authenticates the dashboard's server-side calls to the API and must be
+ # identical in both services. Generate a strong value when it's empty or a known placeholder.
+ if is_insecure_token_value "${private_server_key}"; then
+ upsert_env_value PRIVATE_SERVER_KEY "$(generate_secure_token)"
+ echo "Generated secure PRIVATE_SERVER_KEY in .env"
+ fi
+}
+
+ensure_secure_betterauth_secret() {
+ if [[ ! -f "${ENV_FILE}" ]]; then
+ touch "${ENV_FILE}"
+ fi
+
+ local secret
+ secret="$(get_env_value BETTER_AUTH_SECRET)"
+
+ # Only (re)generate when the value is empty or a known placeholder; never clobber
+ # a strong secret the user has set themselves.
+ if is_insecure_token_value "${secret}"; then
+ upsert_env_value BETTER_AUTH_SECRET "$(generate_secure_token)"
+ echo "Generated secure BETTER_AUTH_SECRET in .env"
+ fi
+}
+
+is_local_origin() {
+ local value="$1"
+ case "${value}" in
+ "" | *localhost* | *127.0.0.1* | *0.0.0.0*)
+ return 0
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+# Point browser-facing storage URLs at the public domain when DASHBOARD_ORIGIN is not
+# localhost. MinIO media is served on :9000 — operators must reverse-proxy /media to it.
+# `mode` is "bundled" (ensure_minio_env) or "external" (ensure_storage_env's --no-minio
+# path). For bundled MinIO, OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL is script-owned, so we
+# keep it in sync with DASHBOARD_ORIGIN on every run — otherwise changing DASHBOARD_ORIGIN
+# after install would leave media links pointed at the old domain forever. For external
+# storage it's operator-owned (their CDN/bucket URL), so we only fill in a default and
+# never clobber an explicit value.
+derive_public_storage_urls() {
+ local mode="$1"
+ local dashboard_origin public_endpoint media_base
+ dashboard_origin="$(get_env_value DASHBOARD_ORIGIN)"
+ public_endpoint="$(get_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT)"
+ media_base="$(get_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL)"
+
+ if ! is_local_origin "${dashboard_origin}"; then
+ local origin="${dashboard_origin%/}"
+ local derived="${origin}/media"
+ # Public media is served from the public-download `media` bucket, so deriving its base
+ # URL from the domain is what fixes broken media links in served pages. We deliberately
+ # do NOT touch OBJECT_STORAGE_PUBLIC_ENDPOINT (the S3 API endpoint) — pointing it at the
+ # dashboard origin would break presigned URLs for the non-public videos/documents buckets.
+ if [[ "${mode}" == "bundled" ]]; then
+ if [[ "${media_base}" != "${derived}" ]]; then
+ upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${derived}"
+ echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${derived})."
+ echo " -> Ensure your reverse proxy routes ${origin}/media to MinIO (port 9000)."
+ echo " -> For presigned access to the videos/documents buckets behind a domain, expose"
+ echo " MinIO's S3 API on its own route and set OBJECT_STORAGE_PUBLIC_ENDPOINT explicitly."
+ fi
+ else
+ if is_local_origin "${media_base}"; then
+ upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${derived}"
+ echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${derived})."
+ fi
+ fi
+ else
+ # Local demo: ensure localhost defaults are present.
+ if [[ -z "${public_endpoint}" ]]; then
+ upsert_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT "http://localhost:9000"
+ fi
+ if [[ -z "${media_base}" ]]; then
+ upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "http://localhost:9000/media"
+ fi
+ fi
+}
+
+ensure_minio_env() {
+ if [[ ! -f "${ENV_FILE}" ]]; then
+ touch "${ENV_FILE}"
+ fi
+
+ local minio_password
+ minio_password="$(get_env_value MINIO_ROOT_PASSWORD)"
+
+ # First provision: randomize credentials whenever the MinIO password is still empty or the
+ # well-known `minioadmin` placeholder shipped in .env.example. (Previously this gated on
+ # OBJECT_STORAGE_ENDPOINT being empty, but .env.example ships it non-empty — so on a normal
+ # copy-the-example first run the randomization was always skipped and minioadmin/minioadmin
+ # survived.) MinIO uses its root credentials as the S3 access key / secret.
+ if [[ -z "${minio_password}" || "${minio_password}" =~ ^[[:space:]]*$ || "${minio_password}" == "minioadmin" ]]; then
+ local minio_user
+ minio_user="cio-$(generate_secure_token | cut -c1-16)"
+ minio_password="$(generate_secure_token)"
+
+ upsert_env_value MINIO_ROOT_USER "${minio_user}"
+ upsert_env_value MINIO_ROOT_PASSWORD "${minio_password}"
+ upsert_env_value OBJECT_STORAGE_ENDPOINT "http://minio:9000"
+ upsert_env_value OBJECT_STORAGE_ACCESS_KEY_ID "${minio_user}"
+ upsert_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY "${minio_password}"
+ upsert_env_value OBJECT_STORAGE_FORCE_PATH_STYLE "true"
+ echo "Provisioned MinIO with randomized credentials in .env"
+ fi
+
+ derive_public_storage_urls "bundled"
+}
+
+# ──────────────────────────────────────────────
+# Shared checks
+# ──────────────────────────────────────────────
+
+# When MinIO is excluded the user must supply external object storage, or uploads/media
+# silently break. Fail fast instead. The endpoint must be set AND not still point at the
+# bundled MinIO host (which won't be running with --no-minio).
+ensure_storage_env() {
+ if [[ "${PROFILE_ARGS}" == *"minio"* ]]; then
+ ensure_minio_env
+ else
+ local external_endpoint
+ external_endpoint="$(get_env_value OBJECT_STORAGE_ENDPOINT)"
+ if [[ -z "${external_endpoint}" || "${external_endpoint}" == *"minio:9000"* ]]; then
+ echo "Error: --no-minio was passed but no external object storage is configured."
+ echo "OBJECT_STORAGE_ENDPOINT is unset or still points at the bundled MinIO (minio:9000)."
+ echo "Configure an external S3-compatible store via OBJECT_STORAGE_* in .env,"
+ echo "or drop --no-minio to use the bundled MinIO."
+ exit 1
+ fi
+ derive_public_storage_urls "external"
+ fi
+}
+
+warn_if_unpinned_version() {
+ local version
+ version="$(get_env_value CIO_VERSION)"
+ if [[ -z "${version}" || "${version}" == "latest" ]]; then
+ echo
+ echo "WARNING: CIO_VERSION is '${version:-unset}' — you are tracking the rolling 'latest' tag."
+ echo " 'latest' moves on every merge to main and may include unreleased code."
+ echo " For production, pin an exact release in .env, e.g.: CIO_VERSION=1.4.2"
+ echo " Available tags: https://hub.docker.com/r/classroomio/api/tags"
+ echo
+ fi
+}
+
+# `docker compose up -d` returns once containers are *started*, not *healthy*. On a fresh
+# deployment the API runs DB migrations before it listens, so the endpoints aren't reachable
+# immediately. Poll with a retry instead of a single curl — and never let a not-yet-ready
+# check abort the script (set -euo pipefail is active), so first runs end cleanly.
+check_endpoint() {
+ local name="$1" url="$2" curl_flags="$3" attempts="$4"
+ local i=0
+ while ((i < attempts)); do
+ if curl "${curl_flags}" --max-time 5 "${url}" >/dev/null 2>&1; then
+ echo "${name} is reachable on ${url}"
+ return 0
+ fi
+ i=$((i + 1))
+ sleep 5
+ done
+ echo "Note: ${name} is not reachable yet at ${url} (waited $((attempts * 5))s)."
+ echo " It may still be starting — the first run migrates the database. Follow logs with:"
+ echo " ./classroomio.sh logs"
+ return 0
+}
+
+wait_for_endpoints() {
+ if command -v curl >/dev/null 2>&1; then
+ echo
+ echo "Waiting for services to come up (first run can take a minute while the API migrates)..."
+ check_endpoint "API" "http://localhost:3081/" "-fsS" 24
+ check_endpoint "Dashboard" "http://localhost:3082/" "-fsSI" 12
+ else
+ echo
+ echo "curl not found, skipped endpoint checks."
+ fi
+}
+
+fetch_if_missing() {
+ local file="$1" url="$2"
+ if [[ -f "${file}" ]]; then
+ echo "Keeping existing $(basename "${file}") (not overwritten)."
+ return 0
+ fi
+ echo "Downloading $(basename "${file}")..."
+ # Download to a temp name and move into place so a failed transfer can never
+ # leave a partial file that a re-run would mistake for a kept local copy.
+ local tmp="${file}.download"
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL --retry 3 --retry-delay 2 -o "${tmp}" "${url}" || { rm -f "${tmp}"; echo "Error: failed to download ${url}"; exit 1; }
+ elif command -v wget >/dev/null 2>&1; then
+ wget -q -O "${tmp}" "${url}" || { rm -f "${tmp}"; echo "Error: failed to download ${url}"; exit 1; }
+ else
+ echo "Error: need curl or wget to download $(basename "${file}")."
+ exit 1
+ fi
+ mv "${tmp}" "${file}"
+}
+
+prepare_env_and_secrets() {
+ ensure_secure_auth_tokens
+ ensure_secure_betterauth_secret
+ ensure_storage_env
+}
+
+# ──────────────────────────────────────────────
+# Commands
+# ──────────────────────────────────────────────
+
+cmd_install() {
+ echo "Installing ClassroomIO..."
+ echo
+
+ if [[ "${USE_IMAGES}" != "true" && ! -f "${BUILD_COMPOSE_FILE}" ]]; then
+ echo "Error: --build install requires a local repo checkout (docker-compose.yaml not found)."
+ echo "Clone the repository instead, or run 'install' without --build to pull pre-built images."
+ exit 1
+ fi
+
+ if [[ "${USE_IMAGES}" == "true" ]]; then
+ # The compose file tracks main (stack topology only — CIO_VERSION pins the app
+ # images). Never overwritten; refresh manually if you need a newer topology:
+ # curl -fsSLO ${RAW_BASE_URL}/docker-compose.images.yaml
+ fetch_if_missing "${IMAGES_COMPOSE_FILE}" "${RAW_BASE_URL}/docker-compose.images.yaml"
+ fi
+ fetch_if_missing "${ENV_EXAMPLE_FILE}" "${RAW_BASE_URL}/.env.example"
+
+ if [[ -f "${ENV_FILE}" ]]; then
+ echo "Keeping existing .env (not overwritten)."
+ else
+ cp "${ENV_EXAMPLE_FILE}" "${ENV_FILE}"
+ echo "Created .env from .env.example"
+ fi
+
+ prepare_env_and_secrets
+ warn_if_unpinned_version
+
+ echo "Review these values in .env before going to production:"
+ echo " DASHBOARD_ORIGIN (your public https:// dashboard URL)"
+ echo " SMTP_* (outgoing email — invites, notifications)"
+ echo " CIO_VERSION (pin an exact release for production)"
+ echo
+
+ cmd_start
+}
+
+cmd_start() {
+ prepare_env_and_secrets
+ warn_if_unpinned_version
+
+ echo "Starting ClassroomIO..."
+ if [[ "${PROFILE_ARGS}" == *"minio"* ]]; then
+ echo "Including MinIO (object storage, default)..."
+ fi
+ if [[ "${USE_IMAGES}" == "true" ]]; then
+ echo "Using pre-built images from Docker Hub (CIO_VERSION=$(get_env_value CIO_VERSION))..."
+ compose pull
+ compose up -d
+ else
+ compose up --build -d
+ fi
+
+ echo
+ echo "Current service status:"
+ compose ps
+
+ wait_for_endpoints
+
+ echo
+ echo "Done. Full stack is running."
+}
+
+cmd_stop() {
+ echo "Stopping ClassroomIO (data volumes are kept)..."
+ compose stop
+ echo "Stopped. Start again with: ./classroomio.sh start"
+}
+
+cmd_restart() {
+ prepare_env_and_secrets
+ echo "Restarting ClassroomIO..."
+ # `compose restart` only bounces the container *process* — it never re-reads .env, so
+ # edits to DASHBOARD_ORIGIN/SMTP_*/LICENSE_KEY/etc. were silently ignored. Recreate
+ # instead: --force-recreate guarantees an actual restart even when config is
+ # unchanged, without pulling new images (that's what `upgrade` is for).
+ compose up -d --force-recreate
+ compose ps
+ wait_for_endpoints
+}
+
+cmd_logs() {
+ compose logs -f --tail=100 "$@"
+}
+
+cmd_backup() {
+ local ts backup_dir db_user db_name
+ ts="$(date +%Y%m%d-%H%M%S)"
+ backup_dir="${ROOT_DIR}/backups"
+ mkdir -p "${backup_dir}"
+
+ db_user="$(get_env_value POSTGRES_USER)"
+ db_user="${db_user:-postgres}"
+ db_name="$(get_env_value POSTGRES_DB)"
+ db_name="${db_name:-classroomio}"
+
+ echo "Backing up Postgres database '${db_name}'..."
+ local db_file="${backup_dir}/classroomio-db-${ts}.sql.gz"
+ # pg_dump failure must fail the backup (and abort an upgrade) — don't let gzip's
+ # exit code mask it.
+ if ! compose exec -T postgres pg_dump -U "${db_user}" "${db_name}" | gzip > "${db_file}"; then
+ rm -f "${db_file}"
+ echo "Error: Postgres backup failed. Is the stack running? (./classroomio.sh start)"
+ return 1
+ fi
+ echo " -> ${db_file}"
+
+ # MinIO volume name is derived from the compose project name — same variable the
+ # -p flag uses, so a project rename can't silently desync the backup target.
+ local minio_volume="${PROJECT_NAME}_minio-data"
+ if docker volume inspect "${minio_volume}" >/dev/null 2>&1; then
+ echo "Backing up MinIO volume '${minio_volume}'..."
+ local minio_file="classroomio-minio-${ts}.tar.gz"
+ # Git Bash (MSYS) rewrites container paths like /data into Windows paths; disable
+ # conversion for this one command and pre-convert the host dir with cygpath.
+ # On Linux/macOS cygpath doesn't exist and the fallback keeps the path as-is.
+ local backup_dir_host
+ backup_dir_host="$(cygpath -w "${backup_dir}" 2>/dev/null || printf '%s' "${backup_dir}")"
+ if ! MSYS_NO_PATHCONV=1 docker run --rm \
+ -v "${minio_volume}:/data:ro" \
+ -v "${backup_dir_host}:/backup" \
+ alpine tar czf "/backup/${minio_file}" -C /data .; then
+ echo "Error: MinIO volume backup failed."
+ return 1
+ fi
+ echo " -> ${backup_dir}/${minio_file}"
+ else
+ # Not an error: --no-minio installs use external storage and have no local volume.
+ echo "MinIO volume '${minio_volume}' not found — skipping object-storage backup."
+ echo " (Using external S3/R2? Back that up with your provider's tools.)"
+ fi
+
+ echo "Backup complete: ${backup_dir}"
+ echo "Copy backups off this machine — a server failure takes local backups with it."
+}
+
+cmd_upgrade() {
+ prepare_env_and_secrets
+ echo "Upgrading ClassroomIO..."
+ echo
+ echo "Step 1/3: backing up before touching images..."
+ cmd_backup
+
+ echo
+ if [[ "${USE_IMAGES}" == "true" ]]; then
+ # CIO_VERSION only selects a published image tag — meaningless for --build,
+ # which always builds whatever is in the local checkout.
+ local current_version
+ current_version="$(get_env_value CIO_VERSION)"
+ echo "Step 2/3: current CIO_VERSION is '${current_version:-unset}'."
+ if [[ -t 0 ]]; then
+ read -r -p "Enter a new version tag to pin (or press Enter to keep the current one): " new_version
+ if [[ -n "${new_version}" ]]; then
+ upsert_env_value CIO_VERSION "${new_version}"
+ echo "Set CIO_VERSION=${new_version} in .env"
+ fi
+ fi
+ warn_if_unpinned_version
+ else
+ echo "Step 2/3: building from source — CIO_VERSION doesn't apply."
+ fi
+
+ if [[ "${USE_IMAGES}" == "true" ]]; then
+ echo "Step 3/3: pulling images and restarting..."
+ compose pull
+ compose up -d
+ else
+ echo "Step 3/3: rebuilding from source and restarting..."
+ compose up --build -d
+ fi
+ compose ps
+ wait_for_endpoints
+ echo
+ echo "Upgrade complete."
+ if [[ "${USE_IMAGES}" == "true" ]]; then
+ echo "Roll back by setting CIO_VERSION back in .env and running: ./classroomio.sh upgrade"
+ fi
+}
+
+show_menu() {
+ echo "ClassroomIO self-host manager"
+ echo
+ echo " 1) Install (first-time setup: fetch files, create .env, start)"
+ echo " 2) Start"
+ echo " 3) Stop"
+ echo " 4) Restart"
+ echo " 5) Upgrade (backs up first, then pulls new images)"
+ echo " 6) View logs"
+ echo " 7) Backup (Postgres dump + MinIO volume archive)"
+ echo " q) Quit"
+ echo
+ read -r -p "Pick an option: " choice
+ case "${choice}" in
+ 1) cmd_install ;;
+ 2) cmd_start ;;
+ 3) cmd_stop ;;
+ 4) cmd_restart ;;
+ 5) cmd_upgrade ;;
+ 6) cmd_logs ;;
+ 7) cmd_backup ;;
+ q|Q) exit 0 ;;
+ *)
+ echo "Unknown option: ${choice}"
+ exit 1
+ ;;
+ esac
+}
+
+# ──────────────────────────────────────────────
+# Argument parsing
+# ──────────────────────────────────────────────
+
+# Options may appear before or after the command: `./classroomio.sh start --build`
+# and `./classroomio.sh --build start` both work.
+COMMAND=""
+COMMAND_ARGS=()
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --build)
+ USE_IMAGES=false
+ COMPOSE_FILE="${BUILD_COMPOSE_FILE}"
+ shift
+ ;;
+ --no-minio)
+ PROFILE_ARGS=""
+ shift
+ ;;
+ -h|--help)
+ print_usage
+ exit 0
+ ;;
+ install|start|stop|restart|upgrade|logs|backup)
+ if [[ -n "${COMMAND}" ]]; then
+ echo "Unexpected extra command: $1 (already running '${COMMAND}')"
+ print_usage
+ exit 1
+ fi
+ COMMAND="$1"
+ shift
+ ;;
+ *)
+ if [[ "${COMMAND}" == "logs" ]]; then
+ COMMAND_ARGS+=("$1")
+ shift
+ else
+ echo "Unknown option: $1"
+ print_usage
+ exit 1
+ fi
+ ;;
+ esac
+done
+
+detect_compose
+
+case "${COMMAND}" in
+ "") show_menu ;;
+ install) cmd_install ;;
+ start) cmd_start ;;
+ stop) cmd_stop ;;
+ restart) cmd_restart ;;
+ upgrade) cmd_upgrade ;;
+ logs) cmd_logs "${COMMAND_ARGS[@]+"${COMMAND_ARGS[@]}"}" ;;
+ backup) cmd_backup ;;
+esac
diff --git a/docker-compose.images.yaml b/docker-compose.images.yaml
index a9bb65db..91466ac6 100644
--- a/docker-compose.images.yaml
+++ b/docker-compose.images.yaml
@@ -132,8 +132,8 @@ services:
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY:-}
OBJECT_STORAGE_FORCE_PATH_STYLE: ${OBJECT_STORAGE_FORCE_PATH_STYLE:-true}
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: ${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL:-}
- # Auth secrets — required. Set strong values in .env (the pull path has no script to
- # generate them); the stack refuses to start otherwise.
+ # Auth secrets — required. ./classroomio.sh generates them into .env; if you run
+ # compose directly, set strong values yourself — the stack refuses to start otherwise.
PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:?set a strong PRIVATE_SERVER_KEY in .env}
DASHBOARD_ORIGIN: ${DASHBOARD_ORIGIN:-}
LICENSE_KEY: ${LICENSE_KEY:-}
diff --git a/docker-compose.yaml b/docker-compose.yaml
index d0888f2b..8a564453 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -126,7 +126,7 @@ services:
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY:-}
OBJECT_STORAGE_FORCE_PATH_STYLE: ${OBJECT_STORAGE_FORCE_PATH_STYLE:-true}
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: ${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL:-}
- # Auth secrets — required. Generated by run-docker-full-stack.sh; set strong values
+ # Auth secrets — required. Generated by classroomio.sh; set strong values
# in .env if you start the stack with `docker compose` directly.
PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:?set a strong PRIVATE_SERVER_KEY in .env}
DASHBOARD_ORIGIN: ${DASHBOARD_ORIGIN:-}
diff --git a/docker/docs/SELF_HOST.md b/docker/docs/SELF_HOST.md
index 4b7d8843..e38de7e5 100644
--- a/docker/docs/SELF_HOST.md
+++ b/docker/docs/SELF_HOST.md
@@ -12,20 +12,20 @@ git clone https://github.com/classroomio/classroomio.git
cd classroomio
cp .env.example .env
# Edit .env — set your dashboard domain and secrets
-./run-docker-full-stack.sh
+./classroomio.sh start
```
-The script builds and starts: **postgres**, **redis**, **minio** (object storage), **api** (`localhost:3081`), **dashboard** (`localhost:3082`), and the **jobs** worker (background processing). Database schema setup runs automatically inside the `api` container on startup (see [`docker/entrypoint-api.sh`](../entrypoint-api.sh); skip with `SKIP_DB_SETUP=true`).
+The script pulls and starts: **postgres**, **redis**, **minio** (object storage), **api** (`localhost:3081`), **dashboard** (`localhost:3082`), and the **jobs** worker (background processing). Pass `--build` to build the application images from this checkout instead of pulling published ones. Database schema setup runs automatically inside the `api` container on startup (see [`docker/entrypoint-api.sh`](../entrypoint-api.sh); skip with `SKIP_DB_SETUP=true`).
It auto-generates secure values for `PRIVATE_SERVER_KEY` and `BETTER_AUTH_SECRET` when they are missing or left at placeholders, and randomizes MinIO credentials on first provision.
-> **The jobs worker is required, not optional.** Without it, video processing, captions/transcription, AI course generation, and most emails are enqueued in Redis but never run — uploads stay stuck "processing" with no error. See [Background Jobs Worker](#background-jobs-worker) below.
+> **The jobs worker is required, not optional.** Without it, video processing, captions/transcription, AI course generation, and most emails are enqueued in Redis but never run: uploads stay stuck "processing" with no error. See [Background Jobs Worker](#background-jobs-worker) below.
## Run from Pre-built Images (no build)
-The Quick Start builds images from source — that needs the full repo and ~8 GB RAM for the
-dashboard build. Most self-hosters should instead **pull** the published `classroomio/{api,dashboard,jobs}`
-images. You only need [`docker-compose.images.yaml`](../../docker-compose.images.yaml) and a `.env`:
+The Quick Start already pulls the published `classroomio/{api,dashboard,jobs}` images by default.
+To skip cloning the full repository, you only need
+[`docker-compose.images.yaml`](../../docker-compose.images.yaml) and a `.env`:
```bash
# 1. Get just the compose file + env template (no full clone required)
@@ -37,10 +37,18 @@ CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env p
CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env up -d
```
-From a full clone you can also use the helper: `./run-docker-full-stack.sh --images`.
+Easier: let the lifecycle script do all of the above (it also auto-generates secrets). It works in
+an empty directory: `install` downloads the compose file and `.env.example` for you:
-> Secrets aren't auto-generated in this path (the helper script does that). Set `BETTER_AUTH_SECRET`
-> and `PRIVATE_SERVER_KEY` to strong random values yourself.
+```bash
+curl -fsSLO https://classroomio.com/classroomio.sh
+chmod +x classroomio.sh
+./classroomio.sh install
+```
+
+> If you run the raw `docker compose` commands instead of the script, secrets aren't
+> auto-generated. Set `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` to strong random values
+> yourself.
## Versioning & Upgrades
@@ -65,30 +73,40 @@ CIO_VERSION=1.5.0 docker compose -f docker-compose.images.yaml --env-file .env u
Schema migrations run automatically on `api` startup (`db:setup`), so upgrades need no manual DB
step. If a release consolidates ("squashes") older migrations, the startup baseline detects an
-existing instance and adopts the new baseline as already-applied — your database is upgraded in
+existing instance and adopts the new baseline as already-applied, and your database is upgraded in
place without re-running schema that already exists.
-(Maintainers: every published image is boot-smoke-tested in CI before it ships — see
+(Maintainers: every published image is boot-smoke-tested in CI before it ships. See
[`PUBLISHING_IMAGES.md`](PUBLISHING_IMAGES.md).)
## Environment Variables
All Docker services read from a single root `.env` file. See [`.env.example`](../../.env.example) for the full annotated template with required/optional grouping.
-Key points:
+Two variables actually need your attention: `PUBLIC_IS_SELFHOSTED=true` and `DASHBOARD_ORIGIN`,
+which you should set to your real domain in production (it also drives public media URLs; see
+[Production Object Storage](#production-object-storage)). Everything else below has a sane
+default.
-- **Required:** `PUBLIC_IS_SELFHOSTED=true`, `DASHBOARD_ORIGIN` (set this to your real domain in production — it also drives public media URLs, see [Production Object Storage](#production-object-storage)).
-- **API routing:** Browser dashboard calls go to the dashboard origin and are proxied to `PRIVATE_SERVER_URL` (default: `http://api:3081`). You do not need API/dashboard cookie-domain matching for normal dashboard auth.
-- **Direct API access (optional):** Set `PUBLIC_SERVER_URL` and `TRUSTED_ORIGINS` only if browsers or third-party clients need to call the API origin directly.
-- **CSP (runtime):** `ALLOWED_EXTERNAL_DOMAINS` (overrides all) or per-directive: `CSP_SCRIPT_SRC_DOMAINS`, `CSP_STYLE_SRC_DOMAINS`, `CSP_CONNECT_SRC_DOMAINS`, `CSP_FRAME_SRC_DOMAINS`, `CSP_FONT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`. These are read at container startup — no image rebuild needed. The API does not need to be added for normal dashboard calls.
-- **Auth cookies:** No cookie-domain env is needed. The dashboard proxy makes auth first-party and Better Auth sets host-only dashboard cookies.
-- **Auto-generated:** `PRIVATE_SERVER_KEY`, `BETTER_AUTH_SECRET` (by `./run-docker-full-stack.sh`). A strong value you set yourself is never overwritten.
-- **Auto-configured:** All `MINIO_*` / `OBJECT_STORAGE_*` vars (by the startup script, with randomized MinIO credentials).
-- **Email (effectively required):** SMTP (or Zoho). See [Email](#email) — without it, signup verification, password reset, and invites do not send.
-- **Optional:** Google OAuth, Unsplash, `LICENSE_KEY` (enterprise).
-- **Optional — upload limits:** `UPLOAD_MAX_*_MB` vars (documents, images, videos, assignment files, etc.). See [`.env.example`](../../.env.example). Passed through to both `api` and `dashboard` containers; unset values use built-in defaults. Raise your reverse-proxy body-size limit too if you increase these.
+Routing and auth are handled for you. Browser dashboard calls go to the dashboard origin and get
+proxied to `PRIVATE_SERVER_URL` (default `http://api:3081`), so normal dashboard auth needs no
+cookie-domain matching between the API and dashboard: the proxy makes it first-party, and Better
+Auth sets host-only cookies. Only set `PUBLIC_SERVER_URL` and `TRUSTED_ORIGINS` if a browser or
+third-party client needs to call the API directly. CSP is read at container startup rather than
+baked into the image, so `ALLOWED_EXTERNAL_DOMAINS` (or the per-directive `CSP_SCRIPT_SRC_DOMAINS`,
+`CSP_STYLE_SRC_DOMAINS`, `CSP_CONNECT_SRC_DOMAINS`, `CSP_FRAME_SRC_DOMAINS`, `CSP_FONT_SRC_DOMAINS`,
+`CSP_MEDIA_SRC_DOMAINS` vars) take effect after `./classroomio.sh restart` (no image rebuild
+needed), and none of it needs to include the API for normal dashboard calls.
-`PRIVATE_SERVER_KEY` must be the same value in both API and dashboard — the script ensures this.
+`PRIVATE_SERVER_KEY` and `BETTER_AUTH_SECRET` are generated for you by `./classroomio.sh` if you
+leave them blank, and a value you set yourself is never overwritten; the script also ensures
+`PRIVATE_SERVER_KEY` matches between API and dashboard. The same goes for the `MINIO_*` /
+`OBJECT_STORAGE_*` vars: the startup script configures them with randomized credentials. Email is
+effectively required: without SMTP (or Zoho) configured, signup verification, password resets, and
+invites silently don't send (see [Email](#email)). Google OAuth, Unsplash, and `LICENSE_KEY` stay
+off until you set them. If you raise the `UPLOAD_MAX_*_MB` vars (documents, images, videos,
+assignment files; full list in [`.env.example`](../../.env.example)), set the same values on both
+`api` and `dashboard`, and raise your reverse-proxy body-size limit to match.
## Verify
@@ -109,7 +127,7 @@ docker exec cio-api pnpm --filter @cio/db db:setup:seed
## Command Reference
-All commands use this prefix — abbreviated as `dc` below:
+All commands use this prefix, abbreviated as `dc` below:
```
dc = docker compose --env-file .env -p classroomio -f docker-compose.yaml
@@ -117,14 +135,18 @@ dc = docker compose --env-file .env -p classroomio -f docker-compose.yaml
| Task | Command |
|------|---------|
-| Full stack (pulls pre-built images, with MinIO) | `./run-docker-full-stack.sh` |
-| Build from source instead of pulling | `./run-docker-full-stack.sh --build` |
-| Exclude MinIO | `./run-docker-full-stack.sh --no-minio` |
+| Interactive lifecycle menu (install/start/stop/upgrade/logs/backup) | `./classroomio.sh` |
+| Full stack (pulls pre-built images, with MinIO) | `./classroomio.sh start` |
+| Build from source instead of pulling | `./classroomio.sh start --build` |
+| Exclude MinIO | `./classroomio.sh start --no-minio` |
+| Back up Postgres + MinIO volume | `./classroomio.sh backup` |
+| Upgrade published images (backs up first) | `./classroomio.sh upgrade` |
+| Upgrade a source build (backs up first, then rebuilds) | `./classroomio.sh upgrade --build` |
| API-only smoke test | `dc up --build -d postgres redis api` |
| Service status | `dc ps` |
| Stream logs | `dc logs -f api dashboard` |
| Rebuild one service | `dc up -d --build api` |
-| Restart (env change, no rebuild) | `dc restart api dashboard` |
+| Restart (recreates containers, applies .env changes) | `./classroomio.sh restart` |
| Stop | `dc down` |
| Stop + delete volumes | `dc down -v` |
@@ -177,11 +199,11 @@ Data is preserved in the `minio-data` volume.
The `jobs` service (`cio-jobs`) is a BullMQ worker that processes everything ClassroomIO does
asynchronously:
-- **Video processing** — probe, thumbnails, audio extraction, transcoding
-- **Transcription / captions** — via OpenAI Whisper (needs `OPENAI_API_KEY`)
-- **AI course generation** — the "generate a course with AI" agent
-- **Emails** — invites, notifications, and other non-auth emails
-- **Maintenance** — periodic cleanup
+- **Video processing:** probe, thumbnails, audio extraction, transcoding
+- **Transcription / captions:** via OpenAI Whisper (needs `OPENAI_API_KEY`)
+- **AI course generation:** the "generate a course with AI" agent
+- **Emails:** invites, notifications, and other non-auth emails
+- **Maintenance:** periodic cleanup
It shares Redis and the database with the API. It is started automatically by the full-stack
script. If it is not running, jobs accumulate in Redis and silently never complete.
@@ -211,7 +233,7 @@ The bundled MinIO is fine for a single host. Two things matter for a real deploy
1. **Public media URLs must point at your domain, not `localhost`.** Media URLs are embedded in
pages served to browsers, so `http://localhost:9000/...` breaks for every remote visitor. The
startup script derives `OBJECT_STORAGE_PUBLIC_ENDPOINT` and
- `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` from `DASHBOARD_ORIGIN` when it is a real domain — make
+ `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` from `DASHBOARD_ORIGIN` when it is a real domain. Make
sure your reverse proxy routes `/media` to MinIO on port 9000 (or set those two
variables explicitly).
2. **External S3 / Cloudflare R2 instead of MinIO.** Run with `--no-minio` and set the
@@ -222,7 +244,7 @@ The bundled MinIO is fine for a single host. Two things matter for a real deploy
## Backups & Persistence
All state lives in named Docker volumes: `postgres-data`, `redis-data`, `minio-data`
-(see `docker-compose.yaml`). **`docker compose ... down -v` deletes them — and all your
+(see `docker-compose.yaml`). **`docker compose ... down -v` deletes them, and all your
data.** Back up regularly:
```bash
@@ -240,7 +262,7 @@ Restore the database into a fresh stack with `psql`/`pg_restore` against `cio-po
API replicas**, set `SKIP_DB_SETUP=true` on all but one to avoid concurrent migration races, and
run setup once out-of-band. The worker queues can also be scaled independently by overriding the
`jobs` container command with the per-queue `start:*` scripts (e.g. `start:media`,
-`start:media-transcribe`) — see `apps/jobs/package.json`.
+`start:media-transcribe`). See `apps/jobs/package.json`.
## HLS Video Playback
@@ -259,8 +281,8 @@ docker compose --env-file .env -p classroomio -f docker-compose.yaml up -d --bui
# Both (e.g. shared packages/* changed)
docker compose --env-file .env -p classroomio -f docker-compose.yaml up -d --build api dashboard
-# Full stack
-./run-docker-full-stack.sh
+# Full stack (backs up first)
+./classroomio.sh upgrade --build
```
## Troubleshooting
@@ -277,7 +299,7 @@ docker container prune -f # free stopped containers
> **Warning:** `docker volume prune` removes volumes not used by any container. If run after `docker compose ... down`, it deletes `postgres-data`, `redis-data`, and `minio-data`. Use only the prunes above to free space safely.
-Then restart: `./run-docker-full-stack.sh`
+Then restart: `./classroomio.sh start`
### "The database system is in recovery mode" (57P03)
@@ -293,7 +315,7 @@ docker compose --env-file .env -p classroomio -f docker-compose.yaml restart min
### SMTP Errors in Logs
-`ECONNREFUSED 127.0.0.1:465` means SMTP is not configured. This does not block startup — configure SMTP or Zoho when you need email.
+`ECONNREFUSED 127.0.0.1:465` means SMTP is not configured. This does not block startup; configure SMTP or Zoho when you need email.
### Port Conflicts
@@ -304,5 +326,5 @@ lsof -nP -iTCP:3082 -sTCP:LISTEN
## Related
-- [Self-hosting docs](https://classroomio.com/docs/self-hosted/docker) — published guide with Docker, Railway, and Coolify instructions
-- [`docker/docs/PUBLISHING_IMAGES.md`](PUBLISHING_IMAGES.md) — publishing Docker images to Docker Hub
+- [Self-hosting docs](https://classroomio.com/docs/self-hosted/docker): published guide with Docker, Railway, and Coolify instructions
+- [`docker/docs/PUBLISHING_IMAGES.md`](PUBLISHING_IMAGES.md): publishing Docker images to Docker Hub
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9d17edf1..288de06c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -635,8 +635,8 @@ importers:
specifier: 7.0.9
version: 7.0.9(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.4)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.54.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0)
blume:
- specifier: 1.1.2
- version: 1.1.2(864af55737aed5e342af6da019f32ece)
+ specifier: 1.2.0
+ version: 1.2.0(864af55737aed5e342af6da019f32ece)
mermaid:
specifier: ^11.16.0
version: 11.16.0
@@ -1561,7 +1561,7 @@ importers:
version: 2.1.1
jsrepo:
specifier: ^3.5.1
- version: 3.5.1(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3))
+ version: 3.5.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3))
layerchart:
specifier: 2.0.0-next.27
version: 2.0.0-next.27(patch_hash=51dabc4b4462058655e01199047915e5582cfd0492e97d6ecd0c244dc17e5a3a)(svelte@5.46.0)
@@ -2166,18 +2166,10 @@ packages:
resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
engines: {node: '>=6.9.0'}
- '@babel/helper-string-parser@7.27.1':
- resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
- engines: {node: '>=6.9.0'}
-
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
- '@babel/helper-validator-identifier@7.28.5':
- resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
- engines: {node: '>=6.9.0'}
-
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
@@ -2723,10 +2715,6 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
- '@babel/types@7.29.0':
- resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
- engines: {node: '>=6.9.0'}
-
'@babel/types@7.29.7':
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
@@ -3019,15 +3007,9 @@ packages:
'@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
- '@emnapi/core@1.7.1':
- resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
-
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
- '@emnapi/wasi-threads@1.1.0':
- resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
-
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
@@ -4900,9 +4882,6 @@ packages:
cpu: [x64]
os: [win32]
- '@napi-rs/wasm-runtime@1.1.1':
- resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
-
'@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
@@ -7645,9 +7624,6 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
- '@tybys/wasm-util@0.10.1':
- resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
-
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
@@ -8772,8 +8748,8 @@ packages:
bluebird@3.4.7:
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
- blume@1.1.2:
- resolution: {integrity: sha512-AnW/vqsP4Fp2t1ZMW6GWYoWNbsoShuaoLBA1xGebW4W2ZH+vNR9UJcxGdOxZ3idF9OkyJbNjZTkY8J3S7JuIAg==}
+ blume@1.2.0:
+ resolution: {integrity: sha512-VA8TJfSgTp1bRvCPJjufZl+coMRcDSQZvQ5Mx4Qut14TjdO1nB6PY2oOv6zZI11IAQpX/ft9l7i1KzNeXXxNLQ==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
@@ -12565,9 +12541,6 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
- oniguruma-parser@0.12.1:
- resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
-
oniguruma-parser@0.12.2:
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
@@ -16643,7 +16616,7 @@ snapshots:
'@babel/code-frame@7.27.1':
dependencies:
- '@babel/helper-validator-identifier': 7.28.5
+ '@babel/helper-validator-identifier': 7.29.7
js-tokens: 4.0.0
picocolors: 1.1.1
@@ -16700,7 +16673,7 @@ snapshots:
'@babel/generator@7.28.5':
dependencies:
'@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/types': 7.29.7
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
@@ -16715,7 +16688,7 @@ snapshots:
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
- '@babel/types': 7.29.0
+ '@babel/types': 7.29.7
'@babel/helper-compilation-targets@7.27.2':
dependencies:
@@ -16741,7 +16714,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5)
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
- '@babel/traverse': 7.28.5
+ '@babel/traverse': 7.29.0
semver: 6.3.1
transitivePeerDependencies:
- supports-color
@@ -16754,7 +16727,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0)
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
- '@babel/traverse': 7.28.5
+ '@babel/traverse': 7.29.0
semver: 6.3.1
transitivePeerDependencies:
- supports-color
@@ -16769,8 +16742,8 @@ snapshots:
'@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-compilation-targets': 7.27.2
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-compilation-targets': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
debug: 4.4.3
lodash.debounce: 4.0.8
resolve: 1.22.11
@@ -16781,7 +16754,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.28.5':
dependencies:
- '@babel/traverse': 7.28.5
+ '@babel/traverse': 7.29.0
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -16789,7 +16762,7 @@ snapshots:
'@babel/helper-module-imports@7.27.1':
dependencies:
'@babel/traverse': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -16804,7 +16777,7 @@ snapshots:
dependencies:
'@babel/core': 7.28.5
'@babel/helper-module-imports': 7.27.1
- '@babel/helper-validator-identifier': 7.28.5
+ '@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.28.5
transitivePeerDependencies:
- supports-color
@@ -16813,7 +16786,7 @@ snapshots:
dependencies:
'@babel/core': 7.28.5
'@babel/helper-module-imports': 7.28.6
- '@babel/helper-validator-identifier': 7.28.5
+ '@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -16822,7 +16795,7 @@ snapshots:
dependencies:
'@babel/core': 7.29.0
'@babel/helper-module-imports': 7.28.6
- '@babel/helper-validator-identifier': 7.28.5
+ '@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -16842,7 +16815,7 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-annotate-as-pure': 7.27.3
'@babel/helper-wrap-function': 7.28.3
- '@babel/traverse': 7.28.5
+ '@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -16851,7 +16824,7 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-member-expression-to-functions': 7.28.5
'@babel/helper-optimise-call-expression': 7.27.1
- '@babel/traverse': 7.28.5
+ '@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -16860,31 +16833,27 @@ snapshots:
'@babel/core': 7.29.0
'@babel/helper-member-expression-to-functions': 7.28.5
'@babel/helper-optimise-call-expression': 7.27.1
- '@babel/traverse': 7.28.5
+ '@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
'@babel/traverse': 7.29.0
- '@babel/types': 7.29.0
+ '@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
- '@babel/helper-string-parser@7.27.1': {}
-
'@babel/helper-string-parser@7.29.7': {}
- '@babel/helper-validator-identifier@7.28.5': {}
-
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/helper-validator-option@7.27.1': {}
'@babel/helper-wrap-function@7.28.3':
dependencies:
- '@babel/template': 7.27.2
- '@babel/traverse': 7.28.5
+ '@babel/template': 7.28.6
+ '@babel/traverse': 7.29.0
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -16892,7 +16861,7 @@ snapshots:
'@babel/helpers@7.28.4':
dependencies:
'@babel/template': 7.27.2
- '@babel/types': 7.28.5
+ '@babel/types': 7.29.7
'@babel/helpers@7.29.2':
dependencies:
@@ -16901,7 +16870,7 @@ snapshots:
'@babel/parser@7.28.5':
dependencies:
- '@babel/types': 7.28.5
+ '@babel/types': 7.29.7
'@babel/parser@7.29.7':
dependencies:
@@ -16949,22 +16918,42 @@ snapshots:
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.28.5)':
dependencies:
@@ -16981,15 +16970,30 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
+
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.5)':
dependencies:
@@ -17004,52 +17008,92 @@ snapshots:
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.28.5)':
dependencies:
@@ -17238,7 +17282,7 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5)
'@babel/helper-plugin-utils': 7.28.6
- '@babel/helper-validator-identifier': 7.28.5
+ '@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.28.5
transitivePeerDependencies:
- supports-color
@@ -17522,7 +17566,7 @@ snapshots:
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
- '@babel/types': 7.28.5
+ '@babel/types': 7.29.7
esutils: 2.0.3
'@babel/preset-typescript@7.28.5(@babel/core@7.28.5)':
@@ -17553,7 +17597,7 @@ snapshots:
dependencies:
'@babel/code-frame': 7.27.1
'@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/types': 7.29.7
'@babel/template@7.28.6':
dependencies:
@@ -17568,7 +17612,7 @@ snapshots:
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.5
'@babel/template': 7.27.2
- '@babel/types': 7.28.5
+ '@babel/types': 7.29.7
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -17587,13 +17631,8 @@ snapshots:
'@babel/types@7.28.5':
dependencies:
- '@babel/helper-string-parser': 7.27.1
- '@babel/helper-validator-identifier': 7.28.5
-
- '@babel/types@7.29.0':
- dependencies:
- '@babel/helper-string-parser': 7.27.1
- '@babel/helper-validator-identifier': 7.28.5
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
'@babel/types@7.29.7':
dependencies:
@@ -17833,22 +17872,11 @@ snapshots:
tslib: 2.8.1
optional: true
- '@emnapi/core@1.7.1':
- dependencies:
- '@emnapi/wasi-threads': 1.1.0
- tslib: 2.8.1
- optional: true
-
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
- '@emnapi/wasi-threads@1.1.0':
- dependencies:
- tslib: 2.8.1
- optional: true
-
'@emnapi/wasi-threads@1.2.2':
dependencies:
tslib: 2.8.1
@@ -19096,7 +19124,7 @@ snapshots:
'@jest/transform@29.7.0':
dependencies:
- '@babel/core': 7.28.5
+ '@babel/core': 7.29.0
'@jest/types': 29.6.3
'@jridgewell/trace-mapping': 0.3.31
babel-plugin-istanbul: 6.1.1
@@ -19310,13 +19338,6 @@ snapshots:
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
optional: true
- '@napi-rs/wasm-runtime@1.1.1':
- dependencies:
- '@emnapi/core': 1.7.1
- '@emnapi/runtime': 1.11.1
- '@tybys/wasm-util': 0.10.1
- optional: true
-
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
'@emnapi/core': 1.11.1
@@ -19641,9 +19662,12 @@ snapshots:
'@oxc-parser/binding-openharmony-arm64@0.112.0':
optional: true
- '@oxc-parser/binding-wasm32-wasi@0.112.0':
+ '@oxc-parser/binding-wasm32-wasi@0.112.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
- '@napi-rs/wasm-runtime': 1.1.1
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
+ transitivePeerDependencies:
+ - '@emnapi/core'
+ - '@emnapi/runtime'
optional: true
'@oxc-parser/binding-win32-arm64-msvc@0.112.0':
@@ -22605,11 +22629,6 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
- '@tybys/wasm-util@0.10.1':
- dependencies:
- tslib: 2.8.1
- optional: true
-
'@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
@@ -22619,8 +22638,8 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
- '@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
@@ -23866,6 +23885,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ babel-jest@29.7.0(@babel/core@7.29.0):
+ dependencies:
+ '@babel/core': 7.29.0
+ '@jest/transform': 29.7.0
+ '@types/babel__core': 7.20.5
+ babel-plugin-istanbul: 6.1.1
+ babel-preset-jest: 29.6.3(@babel/core@7.29.0)
+ chalk: 4.1.2
+ graceful-fs: 4.2.11
+ slash: 3.0.0
+ transitivePeerDependencies:
+ - supports-color
+
babel-plugin-istanbul@6.1.1:
dependencies:
'@babel/helper-plugin-utils': 7.28.6
@@ -23878,7 +23910,7 @@ snapshots:
babel-plugin-jest-hoist@29.6.3:
dependencies:
- '@babel/template': 7.27.2
+ '@babel/template': 7.28.6
'@babel/types': 7.29.7
'@types/babel__core': 7.20.5
'@types/babel__traverse': 7.28.0
@@ -23930,12 +23962,37 @@ snapshots:
'@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5)
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5)
+ babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0):
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0)
+ '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0)
+ '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0)
+ '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
+ '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0)
+ '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0)
+ '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0)
+ '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0)
+ '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0)
+
babel-preset-jest@29.6.3(@babel/core@7.28.5):
dependencies:
'@babel/core': 7.28.5
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5)
+ babel-preset-jest@29.6.3(@babel/core@7.29.0):
+ dependencies:
+ '@babel/core': 7.29.0
+ babel-plugin-jest-hoist: 29.6.3
+ babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0)
+
bail@2.0.2: {}
balanced-match@1.0.2: {}
@@ -24076,7 +24133,7 @@ snapshots:
bluebird@3.4.7: {}
- blume@1.1.2(864af55737aed5e342af6da019f32ece):
+ blume@1.2.0(864af55737aed5e342af6da019f32ece):
dependencies:
'@astrojs/check': 0.9.9(prettier@3.8.2)(typescript@6.0.3)
'@astrojs/markdown-satteri': 0.3.4
@@ -25902,7 +25959,7 @@ snapshots:
eventsource@3.0.7:
dependencies:
- eventsource-parser: 3.0.6
+ eventsource-parser: 3.0.8
execa@5.1.1:
dependencies:
@@ -25969,7 +26026,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
- qs: 6.14.0
+ qs: 6.15.0
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
@@ -26954,7 +27011,7 @@ snapshots:
istanbul-lib-instrument@5.2.1:
dependencies:
- '@babel/core': 7.28.5
+ '@babel/core': 7.29.0
'@babel/parser': 7.29.7
'@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2
@@ -26964,7 +27021,7 @@ snapshots:
istanbul-lib-instrument@6.0.3:
dependencies:
- '@babel/core': 7.28.5
+ '@babel/core': 7.29.0
'@babel/parser': 7.29.7
'@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2
@@ -27060,10 +27117,10 @@ snapshots:
jest-config@29.7.0(@types/node@20.19.27)(ts-node@10.9.2(@types/node@20.19.27)(typescript@5.9.3)):
dependencies:
- '@babel/core': 7.28.5
+ '@babel/core': 7.29.0
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
- babel-jest: 29.7.0(@babel/core@7.28.5)
+ babel-jest: 29.7.0(@babel/core@7.29.0)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
@@ -27091,10 +27148,10 @@ snapshots:
jest-config@29.7.0(@types/node@24.10.4)(ts-node@10.9.2(@types/node@20.19.27)(typescript@5.9.3)):
dependencies:
- '@babel/core': 7.28.5
+ '@babel/core': 7.29.0
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
- babel-jest: 29.7.0(@babel/core@7.28.5)
+ babel-jest: 29.7.0(@babel/core@7.29.0)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
@@ -27180,7 +27237,7 @@ snapshots:
jest-message-util@29.7.0:
dependencies:
- '@babel/code-frame': 7.27.1
+ '@babel/code-frame': 7.29.0
'@jest/types': 29.6.3
'@types/stack-utils': 2.0.3
chalk: 4.1.2
@@ -27276,15 +27333,15 @@ snapshots:
jest-snapshot@29.7.0:
dependencies:
- '@babel/core': 7.28.5
- '@babel/generator': 7.28.5
- '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.5)
- '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.5)
+ '@babel/core': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
+ '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
'@babel/types': 7.29.7
'@jest/expect-utils': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5)
+ babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0)
chalk: 4.1.2
expect: 29.7.0
graceful-fs: 4.2.11
@@ -27484,14 +27541,17 @@ snapshots:
dompurify: 2.5.8
html2canvas: 1.4.1
- jsrepo@3.5.1(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3)):
+ jsrepo@3.5.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3)):
dependencies:
commander: 14.0.3
- oxc-parser: 0.112.0
+ oxc-parser: 0.112.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
unconfig: 7.5.0
optionalDependencies:
svelte: 5.46.0
vue: 3.5.26(typescript@6.0.3)
+ transitivePeerDependencies:
+ - '@emnapi/core'
+ - '@emnapi/runtime'
jszip@3.10.1:
dependencies:
@@ -28764,8 +28824,6 @@ snapshots:
dependencies:
mimic-function: 5.0.1
- oniguruma-parser@0.12.1: {}
-
oniguruma-parser@0.12.2: {}
oniguruma-to-es@2.3.0:
@@ -28776,7 +28834,7 @@ snapshots:
oniguruma-to-es@4.3.4:
dependencies:
- oniguruma-parser: 0.12.1
+ oniguruma-parser: 0.12.2
regex: 6.1.0
regex-recursion: 6.0.2
@@ -28838,7 +28896,7 @@ snapshots:
lodash.isequal: 4.5.0
vali-date: 1.0.0
- oxc-parser@0.112.0:
+ oxc-parser@0.112.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1):
dependencies:
'@oxc-project/types': 0.112.0
optionalDependencies:
@@ -28858,10 +28916,13 @@ snapshots:
'@oxc-parser/binding-linux-x64-gnu': 0.112.0
'@oxc-parser/binding-linux-x64-musl': 0.112.0
'@oxc-parser/binding-openharmony-arm64': 0.112.0
- '@oxc-parser/binding-wasm32-wasi': 0.112.0
+ '@oxc-parser/binding-wasm32-wasi': 0.112.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
'@oxc-parser/binding-win32-arm64-msvc': 0.112.0
'@oxc-parser/binding-win32-ia32-msvc': 0.112.0
'@oxc-parser/binding-win32-x64-msvc': 0.112.0
+ transitivePeerDependencies:
+ - '@emnapi/core'
+ - '@emnapi/runtime'
p-limit@1.3.0:
dependencies:
@@ -28949,7 +29010,7 @@ snapshots:
parse-json@5.2.0:
dependencies:
- '@babel/code-frame': 7.27.1
+ '@babel/code-frame': 7.29.0
error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
@@ -29203,9 +29264,9 @@ snapshots:
dependencies:
postcss: 8.5.6
- postcss-scss@4.0.9(postcss@8.5.6):
+ postcss-scss@4.0.9(postcss@8.5.19):
dependencies:
- postcss: 8.5.6
+ postcss: 8.5.19
postcss-selector-parser@6.0.10:
dependencies:
@@ -30611,8 +30672,8 @@ snapshots:
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
- postcss: 8.5.6
- postcss-scss: 4.0.9(postcss@8.5.6)
+ postcss: 8.5.19
+ postcss-scss: 4.0.9(postcss@8.5.19)
postcss-selector-parser: 7.1.1
optionalDependencies:
svelte: 5.46.0
diff --git a/run-docker-full-stack.sh b/run-docker-full-stack.sh
deleted file mode 100755
index cddf8c55..00000000
--- a/run-docker-full-stack.sh
+++ /dev/null
@@ -1,326 +0,0 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
-
-ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-BUILD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.yaml"
-IMAGES_COMPOSE_FILE="${ROOT_DIR}/docker-compose.images.yaml"
-PROJECT_NAME="classroomio"
-ENV_FILE="${ROOT_DIR}/.env"
-# Default to PULLING pre-built images (fast). Pass --build to build from source instead.
-USE_IMAGES=true
-BUILD_IMAGES=false
-COMPOSE_FILE="${IMAGES_COMPOSE_FILE}"
-COMPOSE_PROFILES="--profile minio"
-
-print_usage() {
- cat <<'USAGE'
-Usage: ./run-docker-full-stack.sh [--build] [--no-build] [--no-minio]
-
-By default this PULLS the pre-built classroomio/* images from Docker Hub at the tag in
-CIO_VERSION (.env) — no local build, no full repo needed.
-
-Options:
- --build Build the images from source (docker-compose.yaml) instead of pulling.
- --no-build With --build, start containers without rebuilding.
- --no-minio Exclude MinIO (object storage). By default MinIO is included.
- --images Deprecated alias — pulling images is now the default.
- -h, --help Show this help message.
-USAGE
-}
-
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --build)
- USE_IMAGES=false
- BUILD_IMAGES=true
- COMPOSE_FILE="${BUILD_COMPOSE_FILE}"
- shift
- ;;
- --no-build)
- BUILD_IMAGES=false
- shift
- ;;
- --no-minio)
- COMPOSE_PROFILES=""
- shift
- ;;
- --images)
- # Pulling is the default now; kept for backward compatibility.
- USE_IMAGES=true
- BUILD_IMAGES=false
- COMPOSE_FILE="${IMAGES_COMPOSE_FILE}"
- shift
- ;;
- -h|--help)
- print_usage
- exit 0
- ;;
- *)
- echo "Unknown option: $1"
- print_usage
- exit 1
- ;;
- esac
-done
-
-get_env_value() {
- local key="$1"
- local line
- line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)"
- printf '%s' "${line#*=}"
-}
-
-upsert_env_value() {
- local key="$1"
- local value="$2"
- local tmp_file
-
- tmp_file="$(mktemp)"
- awk -v key="${key}" -v value="${value}" '
- BEGIN { updated = 0 }
- $0 ~ ("^" key "=") {
- if (updated == 0) {
- print key "=" value;
- updated = 1;
- }
- next;
- }
- { print }
- END {
- if (updated == 0) {
- print key "=" value;
- }
- }
- ' "${ENV_FILE}" > "${tmp_file}"
-
- mv "${tmp_file}" "${ENV_FILE}"
-}
-
-is_insecure_token_value() {
- local value="$1"
- local normalized
-
- normalized="$(printf '%s' "${value}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
-
- if [[ "${normalized}" =~ ^\"(.*)\"$ ]]; then
- normalized="${BASH_REMATCH[1]}"
- elif [[ "${normalized}" =~ ^\'(.*)\'$ ]]; then
- normalized="${BASH_REMATCH[1]}"
- fi
-
- case "${normalized}" in
- "" | "replace-with-a-long-random-token" | "local-dev-api-key" | "changeme" | "replace-me")
- return 0
- ;;
- replace-with-* | *local-dev* | *change-this* | *your-*)
- return 0
- ;;
- *)
- return 1
- ;;
- esac
-}
-
-generate_secure_token() {
- if command -v openssl >/dev/null 2>&1; then
- openssl rand -hex 32
- return
- fi
-
- if command -v node >/dev/null 2>&1; then
- node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
- return
- fi
-
- if command -v python3 >/dev/null 2>&1; then
- python3 -c "import secrets; print(secrets.token_hex(32))"
- return
- fi
-
- date +%s%N | shasum | awk '{ print $1 }'
-}
-
-ensure_secure_auth_tokens() {
- if [[ ! -f "${ENV_FILE}" ]]; then
- touch "${ENV_FILE}"
- fi
-
- local private_server_key
- private_server_key="$(get_env_value PRIVATE_SERVER_KEY)"
-
- # PRIVATE_SERVER_KEY authenticates the dashboard's server-side calls to the API and must be
- # identical in both services. Generate a strong value when it's empty or a known placeholder.
- if is_insecure_token_value "${private_server_key}"; then
- upsert_env_value PRIVATE_SERVER_KEY "$(generate_secure_token)"
- echo "Generated secure PRIVATE_SERVER_KEY in .env"
- fi
-}
-
-ensure_secure_betterauth_secret() {
- if [[ ! -f "${ENV_FILE}" ]]; then
- touch "${ENV_FILE}"
- fi
-
- local secret
- secret="$(get_env_value BETTER_AUTH_SECRET)"
-
- # Only (re)generate when the value is empty or a known placeholder; never clobber
- # a strong secret the user has set themselves.
- if is_insecure_token_value "${secret}"; then
- upsert_env_value BETTER_AUTH_SECRET "$(generate_secure_token)"
- echo "Generated secure BETTER_AUTH_SECRET in .env"
- fi
-}
-
-is_local_origin() {
- local value="$1"
- case "${value}" in
- "" | *localhost* | *127.0.0.1* | *0.0.0.0*)
- return 0
- ;;
- *)
- return 1
- ;;
- esac
-}
-
-# Point browser-facing storage URLs at the public domain when DASHBOARD_ORIGIN is not
-# localhost. MinIO media is served on :9000 — operators must reverse-proxy /media to it.
-# We only override values that are still empty/localhost so explicit S3/R2 config is kept.
-derive_public_storage_urls() {
- local dashboard_origin public_endpoint media_base
- dashboard_origin="$(get_env_value DASHBOARD_ORIGIN)"
- public_endpoint="$(get_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT)"
- media_base="$(get_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL)"
-
- if ! is_local_origin "${dashboard_origin}"; then
- local origin="${dashboard_origin%/}"
- # Public media is served from the public-download `media` bucket, so deriving its base
- # URL from the domain is what fixes broken media links in served pages. We deliberately
- # do NOT touch OBJECT_STORAGE_PUBLIC_ENDPOINT (the S3 API endpoint) — pointing it at the
- # dashboard origin would break presigned URLs for the non-public videos/documents buckets.
- if is_local_origin "${media_base}"; then
- upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${origin}/media"
- fi
- echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${origin}/media)."
- echo " -> Ensure your reverse proxy routes ${origin}/media to MinIO (port 9000)."
- echo " -> For presigned access to the videos/documents buckets behind a domain, expose"
- echo " MinIO's S3 API on its own route and set OBJECT_STORAGE_PUBLIC_ENDPOINT explicitly."
- else
- # Local demo: ensure localhost defaults are present.
- if [[ -z "${public_endpoint}" ]]; then
- upsert_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT "http://localhost:9000"
- fi
- if [[ -z "${media_base}" ]]; then
- upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "http://localhost:9000/media"
- fi
- fi
-}
-
-ensure_minio_env() {
- if [[ ! -f "${ENV_FILE}" ]]; then
- touch "${ENV_FILE}"
- fi
-
- local minio_password
- minio_password="$(get_env_value MINIO_ROOT_PASSWORD)"
-
- # First provision: randomize credentials whenever the MinIO password is still empty or the
- # well-known `minioadmin` placeholder shipped in .env.example. (Previously this gated on
- # OBJECT_STORAGE_ENDPOINT being empty, but .env.example ships it non-empty — so on a normal
- # copy-the-example first run the randomization was always skipped and minioadmin/minioadmin
- # survived.) MinIO uses its root credentials as the S3 access key / secret.
- if [[ -z "${minio_password}" || "${minio_password}" =~ ^[[:space:]]*$ || "${minio_password}" == "minioadmin" ]]; then
- local minio_user
- minio_user="cio-$(generate_secure_token | cut -c1-16)"
- minio_password="$(generate_secure_token)"
-
- upsert_env_value MINIO_ROOT_USER "${minio_user}"
- upsert_env_value MINIO_ROOT_PASSWORD "${minio_password}"
- upsert_env_value OBJECT_STORAGE_ENDPOINT "http://minio:9000"
- upsert_env_value OBJECT_STORAGE_ACCESS_KEY_ID "${minio_user}"
- upsert_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY "${minio_password}"
- upsert_env_value OBJECT_STORAGE_FORCE_PATH_STYLE "true"
- echo "Provisioned MinIO with randomized credentials in .env"
- fi
-
- derive_public_storage_urls
-}
-
-if ! command -v docker >/dev/null 2>&1; then
- echo "Error: docker is not installed or not on PATH."
- exit 1
-fi
-
-ensure_secure_auth_tokens
-ensure_secure_betterauth_secret
-
-if [[ "${COMPOSE_PROFILES}" == *"minio"* ]]; then
- ensure_minio_env
-else
- # MinIO excluded: the user must supply external object storage, or uploads/media
- # silently break. Fail fast instead. The endpoint must be set AND not still point at the
- # bundled MinIO host (which won't be running with --no-minio).
- external_endpoint="$(get_env_value OBJECT_STORAGE_ENDPOINT)"
- if [[ -z "${external_endpoint}" || "${external_endpoint}" == *"minio:9000"* ]]; then
- echo "Error: --no-minio was passed but no external object storage is configured."
- echo "OBJECT_STORAGE_ENDPOINT is unset or still points at the bundled MinIO (minio:9000)."
- echo "Configure an external S3-compatible store via OBJECT_STORAGE_* in .env,"
- echo "or drop --no-minio to use the bundled MinIO."
- exit 1
- fi
- derive_public_storage_urls
-fi
-
-echo "Starting ClassroomIO Docker full stack..."
-if [[ "${COMPOSE_PROFILES}" != "" ]]; then
- echo "Including MinIO (object storage, default)..."
-fi
-if [[ "${USE_IMAGES}" == "true" ]]; then
- echo "Using pre-built images from Docker Hub (CIO_VERSION=$(get_env_value CIO_VERSION))..."
- docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} pull
- docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up -d
-elif [[ "${BUILD_IMAGES}" == "true" ]]; then
- docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up --build -d
-else
- docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up -d
-fi
-
-echo
-echo "Current service status:"
-docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ps
-
-# `docker compose up -d` returns once containers are *started*, not *healthy*. On a fresh
-# deployment the API runs DB migrations before it listens, so the endpoints aren't reachable
-# immediately. Poll with a retry instead of a single curl — and never let a not-yet-ready
-# check abort the script (set -euo pipefail is active), so first runs end cleanly.
-check_endpoint() {
- local name="$1" url="$2" curl_flags="$3" attempts="$4"
- local i=0
- while ((i < attempts)); do
- if curl ${curl_flags} --max-time 5 "${url}" >/dev/null 2>&1; then
- echo "${name} is reachable on ${url}"
- return 0
- fi
- i=$((i + 1))
- sleep 5
- done
- echo "Note: ${name} is not reachable yet at ${url} (waited $((attempts * 5))s)."
- echo " It may still be starting — the first run migrates the database. Follow logs with:"
- echo " docker compose --env-file \"${ENV_FILE}\" -p \"${PROJECT_NAME}\" -f \"${COMPOSE_FILE}\" logs -f"
- return 0
-}
-
-if command -v curl >/dev/null 2>&1; then
- echo
- echo "Waiting for services to come up (first run can take a minute while the API migrates)..."
- check_endpoint "API" "http://localhost:3081/" "-fsS" 24
- check_endpoint "Dashboard" "http://localhost:3082/" "-fsSI" 12
-else
- echo
- echo "curl not found, skipped endpoint checks."
-fi
-
-echo
-echo "Done. Full stack is running."