refactor: selfhosting docs (#798)f

* Remove the run-docker-full-stack.sh script to streamline the deployment process and eliminate unnecessary complexity.

* fix: update documentation to clarify script behavior for building and pulling images

* update docs

* fix: update script URL to use classroomio.com for downloads

* fix: humanize selfhosting docs and minor amendment

* fix: attend to comment

* fix: humanize docs

* update docs

* fix: adjusted the Restarting ClassroomIO... function

* fix: update commands and documentation for environment variable handling and upgrades

* chore: added space for rebuild
This commit is contained in:
Abdulmuizz Hamzat
2026-08-11 13:33:20 +01:00
committed by GitHub
parent e67c314c46
commit 0dc32b5210
34 changed files with 1721 additions and 1458 deletions
+3 -3
View File
@@ -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
-636
View File
@@ -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 `[email protected]` / `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.
+3 -3
View File
@@ -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
+23 -4
View File
@@ -168,8 +168,14 @@ export default defineConfig({
root: '/self-hosted',
items: [
'/self-hosted',
'/self-hosted/101',
'/self-hosted/architecture',
{
label: 'Platforms',
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']
}
]
}
]
+1 -1
View File
@@ -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
@@ -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
<CardGroup cols={2}>
<Card title="Pick an install path" href="/self-hosted" icon="server">
Docker, DigitalOcean, Railway, Coolify, or Dokploy.
</Card>
<Card title="Architecture" href="/self-hosted/architecture" icon="network">
The services, how they connect, and why the jobs worker needs RAM.
</Card>
<Card title="Configuration" href="/self-hosted/configuration" icon="settings">
Every environment variable the stack reads.
</Card>
<Card title="Enterprise & licensing" href="/self-hosted/enterprise" icon="id-card">
SSO, token auth, no-tracking, and how license validation works.
</Card>
</CardGroup>
@@ -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 <PRIVATE_SERVER_KEY>
```
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.
@@ -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-<timestamp>.sql.gz`: a full `pg_dump` of the database.
- `classroomio-minio-<timestamp>.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
<Steps>
<Step>
### 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
```
</Step>
<Step>
### Restore the database
```zsh title="Terminal"
gunzip -c backups/classroomio-db-<timestamp>.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.
</Step>
<Step>
### 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-<timestamp>.tar.gz"
```
</Step>
<Step>
### Start everything again
```zsh title="Terminal"
./classroomio.sh start
```
</Step>
</Steps>
:::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.
:::
@@ -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).
:::
@@ -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.
@@ -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
<CardGroup cols={2}>
<Card title="Environment & secrets" href="/self-hosted/configuration/secrets" icon="key-round">
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.
</Card>
<Card title="Email (SMTP)" href="/self-hosted/configuration/email" icon="mail">
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
<Card title="Integrations" href="/self-hosted/configuration/integrations" icon="plug">
Google OAuth, Unsplash, OpenAI transcription and AI course generation.
</Card>
<Card title="SSO & licensing" href="/self-hosted/configuration/sso" icon="id-card">
<Card title="Enterprise & licensing" href="/self-hosted/enterprise" icon="id-card">
Set `LICENSE_KEY` to unlock Enterprise features such as SSO.
</Card>
<Card title="Versions & upgrades" href="/self-hosted/configuration/versions" icon="git-branch">
@@ -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).
:::
@@ -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 '<new-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).
:::
@@ -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=<your enterprise 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.
@@ -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 `<DASHBOARD_ORIGIN>/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.
:::
@@ -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
+15 -15
View File
@@ -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
<Step>
### 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.
![Showing list of options to choose from](/docs/coolify-02-services.webp)
@@ -74,13 +74,13 @@ SMTP_SENDER=ClassroomIO <[email protected]>
# 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.
![Setting the dashboard service domain in Coolify](/docs/coolify-05-dashboard-domain.webp)
</Step>
@@ -115,7 +115,7 @@ The dashboard proxies API calls server-side, so you normally only expose the das
<Step>
### 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.
![Coolify deploy logs showing the stack coming up](/docs/coolify-06-deploy-logs.webp)
</Step>
@@ -123,7 +123,7 @@ Click **Deploy**. Coolify pulls `classroomio/{api,dashboard,jobs}:${CIO_VERSION}
<Step>
### 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.
![The running ClassroomIO dashboard sign-up page](/docs/railway-12-signup.webp)
</Step>
@@ -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 password>
SMTP_SENDER=ClassroomIO <[email protected]>
# ── 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.
:::
@@ -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 `<anything>.<ip>.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=<your-droplet-ip>.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.
</Step>
@@ -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.
</Step>
<Step>
@@ -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
<Step>
### 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.
:::
</Step>
@@ -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.
</Step>
<Step>
### Sign up
Open `https://app.<your-domain>` and create the first account — it becomes the organization
Open `https://app.<your-domain>` 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.<your-domain>/...` — if you see `localhost:9000`, `OBJECT_STORAGE_PUBLIC_ENDPOINT` is
`https://s3.<your-domain>/...`. If you see `localhost:9000`, `OBJECT_STORAGE_PUBLIC_ENDPOINT` is
wrong.
</Step>
@@ -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).
+119 -128
View File
@@ -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.
![Selfhost ClassroomIO](/docs/selfhost-classroomio.webp)
## 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:
<Steps>
<Step>
### 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
```
</Step>
<Step>
### 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.
</Step>
<Step>
### 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=<strong 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.
:::
</Step>
</Steps>
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.
<Expandable title="Build from source instead of pulling images">
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).
</Expandable>
## 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).
<Expandable title="Common .env values">
```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=<openssl rand -hex 32>
PRIVATE_SERVER_KEY=<openssl rand -hex 32> # 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=<strong 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):
</Expandable>
```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:
<CardGroup cols={2}>
<Card title="Environment & secrets" href="/self-hosted/configuration/secrets" icon="key-round">
@@ -130,51 +157,15 @@ Every variable the stack reads is documented once in the shared
<Card title="Integrations" href="/self-hosted/configuration/integrations" icon="plug">
Google OAuth, Unsplash, OpenAI, branding.
</Card>
<Card title="SSO & licensing" href="/self-hosted/configuration/sso" icon="id-card">
`LICENSE_KEY` for Enterprise features.
<Card title="Enterprise & licensing" href="/self-hosted/enterprise" icon="id-card">
`LICENSE_KEY` for SSO, token auth, and no-tracking.
</Card>
</CardGroup>
## 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).
+18 -18
View File
@@ -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.
![Adding a Compose service and selecting the server](/docs/dokploy-02-compose-service.webp)
</Step>
@@ -72,10 +72,10 @@ prompted, **select the server** you connected to Dokploy — this is the VPS Cla
<Step>
### 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.
![Choosing the Raw provider and pasting the compose file](/docs/dokploy-03-raw-compose.webp)
@@ -101,7 +101,7 @@ SMTP_PASSWORD=<smtp password>
SMTP_SENDER=ClassroomIO <[email protected]>
```
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.
![Setting the environment variables in Dokploy](/docs/dokploy-04-env-vars.webp)
@@ -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.
</Step>
@@ -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.
![Dokploy deploy logs showing the stack coming up](/docs/dokploy-05-deploy-logs.webp)
</Step>
@@ -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.
![Adding the dashboard domain with port 3082 and Let's Encrypt](/docs/dokploy-06-add-domain.webp)
</Step>
@@ -174,7 +174,7 @@ shared [Configuration](/self-hosted/configuration) section:
<Card title="Integrations" href="/self-hosted/configuration/integrations" icon="plug">
Google OAuth, Unsplash, OpenAI.
</Card>
<Card title="SSO & licensing" href="/self-hosted/configuration/sso" icon="id-card">
<Card title="Enterprise & licensing" href="/self-hosted/enterprise" icon="id-card">
`LICENSE_KEY` for Enterprise features.
</Card>
</CardGroup>
@@ -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=<your enterprise 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.
+23 -20
View File
@@ -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.
</Card>
<Card title="DigitalOcean" href="/self-hosted/digitalocean" icon="droplet">
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.
</Card>
<Card title="Railway" href="/self-hosted/railway" icon="train-front">
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.
+15 -15
View File
@@ -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**.
<Steps>
@@ -49,10 +49,10 @@ Click the **Deploy on Railway** button above. Railway opens the template page wh
<Step>
### 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
<Step>
### 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.
![Railway deploying the ClassroomIO services after clicking Deploy](/docs/railway-07-deploying.webp)
</Step>
<Step>
### 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.
:::
![cio-api crashing on first boot before the dashboard domain exists](/docs/railway-08-first-boot-crash.webp)
@@ -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
@@ -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 <container> | 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`.
+1 -1
View File
@@ -12,7 +12,7 @@ Token Auth requires an **Enterprise** plan.
**Cloud:** [Contact us](mailto:[email protected]) 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
+1 -1
View File
@@ -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"
},
+2
View File
@@ -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}
`;
@@ -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');
};
Executable
+666
View File
@@ -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
+2 -2
View File
@@ -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:-}
+1 -1
View File
@@ -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:-}
+64 -42
View File
@@ -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 `<your-domain>/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
+200 -139
View File
@@ -635,8 +635,8 @@ importers:
specifier: 7.0.9
version: 7.0.9(@astrojs/[email protected])(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])(@vercel/[email protected](@aws-sdk/[email protected])([email protected]([email protected])([email protected])))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
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([email protected])([email protected]([email protected]))
version: 3.5.1(@emnapi/[email protected])(@emnapi/[email protected])([email protected])([email protected]([email protected]))
layerchart:
specifier: 2.0.0-next.27
version: 2.0.0-next.27(patch_hash=51dabc4b4462058655e01199047915e5582cfd0492e97d6ecd0c244dc17e5a3a)([email protected])
@@ -2166,18 +2166,10 @@ packages:
resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
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/[email protected]':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
@@ -3019,15 +3007,9 @@ packages:
'@emnapi/[email protected]':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
'@emnapi/[email protected]':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
'@emnapi/[email protected]':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
'@emnapi/[email protected]':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
'@emnapi/[email protected]':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
@@ -4900,9 +4882,6 @@ packages:
cpu: [x64]
os: [win32]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
'@napi-rs/[email protected]':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
@@ -7645,9 +7624,6 @@ packages:
'@tsconfig/[email protected]':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@tybys/[email protected]':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@tybys/[email protected]':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
@@ -8772,8 +8748,8 @@ packages:
[email protected]:
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'}
[email protected]:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
[email protected]:
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
@@ -16643,7 +16616,7 @@ snapshots:
'@babel/[email protected]':
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/[email protected]':
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/[email protected]':
dependencies:
'@babel/types': 7.29.0
'@babel/types': 7.29.7
'@babel/[email protected]':
dependencies:
@@ -16741,7 +16714,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/helper-replace-supers': 7.27.1(@babel/[email protected])
'@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/[email protected])
'@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/[email protected](@babel/[email protected])':
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/[email protected]':
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/[email protected]':
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/[email protected]':
dependencies:
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/[email protected]': {}
'@babel/[email protected]': {}
'@babel/[email protected]': {}
'@babel/[email protected]': {}
'@babel/[email protected]': {}
'@babel/[email protected]':
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/[email protected]':
dependencies:
'@babel/template': 7.27.2
'@babel/types': 7.28.5
'@babel/types': 7.29.7
'@babel/[email protected]':
dependencies:
@@ -16901,7 +16870,7 @@ snapshots:
'@babel/[email protected]':
dependencies:
'@babel/types': 7.28.5
'@babel/types': 7.29.7
'@babel/[email protected]':
dependencies:
@@ -16949,22 +16918,42 @@ snapshots:
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
@@ -16981,15 +16970,30 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
@@ -17004,52 +17008,92 @@ snapshots:
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/[email protected](@babel/[email protected])':
dependencies:
@@ -17238,7 +17282,7 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-module-transforms': 7.28.3(@babel/[email protected])
'@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/[email protected](@babel/[email protected])':
@@ -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/[email protected]':
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/[email protected]':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@babel/[email protected]':
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/[email protected]':
dependencies:
@@ -17833,22 +17872,11 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/[email protected]':
dependencies:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
optional: true
'@emnapi/[email protected]':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/[email protected]':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/[email protected]':
dependencies:
tslib: 2.8.1
@@ -19096,7 +19124,7 @@ snapshots:
'@jest/[email protected]':
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/[email protected]':
optional: true
'@napi-rs/[email protected]':
dependencies:
'@emnapi/core': 1.7.1
'@emnapi/runtime': 1.11.1
'@tybys/wasm-util': 0.10.1
optional: true
'@napi-rs/[email protected](@emnapi/[email protected])(@emnapi/[email protected])':
dependencies:
'@emnapi/core': 1.11.1
@@ -19641,9 +19662,12 @@ snapshots:
'@oxc-parser/[email protected]':
optional: true
'@oxc-parser/[email protected]':
'@oxc-parser/[email protected](@emnapi/[email protected])(@emnapi/[email protected])':
dependencies:
'@napi-rs/wasm-runtime': 1.1.1
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/[email protected])(@emnapi/[email protected])
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@oxc-parser/[email protected]':
@@ -22605,11 +22629,6 @@ snapshots:
'@tsconfig/[email protected]': {}
'@tybys/[email protected]':
dependencies:
tslib: 2.8.1
optional: true
'@tybys/[email protected]':
dependencies:
tslib: 2.8.1
@@ -22619,8 +22638,8 @@ snapshots:
'@types/[email protected]':
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
[email protected](@babel/[email protected]):
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/[email protected])
chalk: 4.1.2
graceful-fs: 4.2.11
slash: 3.0.0
transitivePeerDependencies:
- supports-color
[email protected]:
dependencies:
'@babel/helper-plugin-utils': 7.28.6
@@ -23878,7 +23910,7 @@ snapshots:
[email protected]:
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/[email protected])
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/[email protected])
[email protected](@babel/[email protected]):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-syntax-async-generators': 7.8.4(@babel/[email protected])
'@babel/plugin-syntax-bigint': 7.8.3(@babel/[email protected])
'@babel/plugin-syntax-class-properties': 7.12.13(@babel/[email protected])
'@babel/plugin-syntax-class-static-block': 7.14.5(@babel/[email protected])
'@babel/plugin-syntax-import-attributes': 7.27.1(@babel/[email protected])
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/[email protected])
'@babel/plugin-syntax-json-strings': 7.8.3(@babel/[email protected])
'@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/[email protected])
'@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/[email protected])
'@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/[email protected])
'@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/[email protected])
'@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/[email protected])
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/[email protected])
'@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/[email protected])
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/[email protected])
[email protected](@babel/[email protected]):
dependencies:
'@babel/core': 7.28.5
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.2.0(@babel/[email protected])
[email protected](@babel/[email protected]):
dependencies:
'@babel/core': 7.29.0
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.2.0(@babel/[email protected])
[email protected]: {}
[email protected]: {}
@@ -24076,7 +24133,7 @@ snapshots:
[email protected]: {}
blume@1.1.2(864af55737aed5e342af6da019f32ece):
blume@1.2.0(864af55737aed5e342af6da019f32ece):
dependencies:
'@astrojs/check': 0.9.9([email protected])([email protected])
'@astrojs/markdown-satteri': 0.3.4
@@ -25902,7 +25959,7 @@ snapshots:
[email protected]:
dependencies:
eventsource-parser: 3.0.6
eventsource-parser: 3.0.8
[email protected]:
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:
[email protected]:
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:
[email protected]:
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:
[email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])):
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/[email protected]8.5)
babel-jest: 29.7.0(@babel/[email protected]9.0)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
@@ -27091,10 +27148,10 @@ snapshots:
[email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])):
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/[email protected]8.5)
babel-jest: 29.7.0(@babel/[email protected]9.0)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
@@ -27180,7 +27237,7 @@ snapshots:
[email protected]:
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:
[email protected]:
dependencies:
'@babel/core': 7.28.5
'@babel/generator': 7.28.5
'@babel/plugin-syntax-jsx': 7.28.6(@babel/[email protected]8.5)
'@babel/plugin-syntax-typescript': 7.28.6(@babel/[email protected]8.5)
'@babel/core': 7.29.0
'@babel/generator': 7.29.1
'@babel/plugin-syntax-jsx': 7.28.6(@babel/[email protected]9.0)
'@babel/plugin-syntax-typescript': 7.28.6(@babel/[email protected]9.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/[email protected]8.5)
babel-preset-current-node-syntax: 1.2.0(@babel/[email protected]9.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
[email protected]([email protected])([email protected]([email protected])):
[email protected](@emnapi/[email protected])(@emnapi/[email protected])([email protected])([email protected]([email protected])):
dependencies:
commander: 14.0.3
oxc-parser: 0.112.0
oxc-parser: 0.112.0(@emnapi/[email protected])(@emnapi/[email protected])
unconfig: 7.5.0
optionalDependencies:
svelte: 5.46.0
vue: 3.5.26([email protected])
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
[email protected]:
dependencies:
@@ -28764,8 +28824,6 @@ snapshots:
dependencies:
mimic-function: 5.0.1
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -28776,7 +28834,7 @@ snapshots:
[email protected]:
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
[email protected]:
[email protected](@emnapi/[email protected])(@emnapi/[email protected]):
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/[email protected])(@emnapi/[email protected])
'@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'
[email protected]:
dependencies:
@@ -28949,7 +29010,7 @@ snapshots:
[email protected]:
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
[email protected]([email protected].6):
[email protected]([email protected].19):
dependencies:
postcss: 8.5.6
postcss: 8.5.19
[email protected]:
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([email protected].6)
postcss: 8.5.19
postcss-scss: 4.0.9([email protected].19)
postcss-selector-parser: 7.1.1
optionalDependencies:
svelte: 5.46.0
-326
View File
@@ -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."