feat: harden self-hosting + add pull-based deploy, Coolify/Railway (#666)

* feat: enhance self-hosting support and CI/CD workflows

- Updated .env.example to clarify auth secret generation and self-hosting mode.
- Added CI workflow for build and lint checks on PRs and main branch pushes.
- Improved docker-publish workflow to trigger on relevant file changes and added multi-arch support.
- Enhanced upload-openapi-spec workflow with caching for dependencies.
- Removed obsolete Dockerfile for API service and added jobs worker to Docker setup.
- Updated self-host documentation to reflect new jobs worker requirement.
- Modified Dockerfiles for API and Dashboard to optimize dependency fetching and installation.
- Enhanced docker-compose.yaml with health checks and jobs worker configuration.
- Updated docker-push.sh to support multi-architecture builds and improved logging.
- Revised documentation for publishing images and self-hosting to include new features and requirements.

* feat(self-host): safe versioned releases + pull-based image pipeline

Builds on the pull-based images compose (docker-compose.images.yaml) to make
pulling pre-built images safe for self-hosters:

- docker-publish: stable-releases + edge tag policy. Git tag vX.Y.Z publishes
  X.Y.Z/X.Y/X/latest; a push to main publishes only :edge (latest never moves
  on a routine merge).
- Pre-publish smoke-test gate: each image is booted against ephemeral
  postgres+redis and healthchecked before any push (docker/smoke-test.sh).
- CIO_VERSION pinning in .env.example; run-docker-full-stack.sh --images flag
  to pull instead of build.
- Docs: SELF_HOST pull-from-images + versioning/upgrade/rollback sections,
  PUBLISHING_IMAGES tag policy + pnpm release flow, self-hosting.mdx quickstart.

* feat: enhance self-hosting support and CI/CD workflows

- Updated .env.example to clarify auth secret generation and self-hosting mode.
- Added CI workflow for build and lint checks on PRs and main branch pushes.
- Improved docker-publish workflow to trigger on relevant file changes and added multi-arch support.
- Enhanced upload-openapi-spec workflow with caching for dependencies.
- Removed obsolete Dockerfile for API service and added jobs worker to Docker setup.
- Updated self-host documentation to reflect new jobs worker requirement.
- Modified Dockerfiles for API and Dashboard to optimize dependency fetching and installation.
- Enhanced docker-compose.yaml with health checks and jobs worker configuration.
- Updated docker-push.sh to support multi-architecture builds and improved logging.
- Revised documentation for publishing images and self-hosting to include new features and requirements.

* ci: run build-only on PRs, defer lint until repo-wide format

* docs: point Railway template link at the new template URL

* fix: address Greptile review + dashboard CI build env

- docker-push.sh: only also-tag :latest for real semver releases, so
  VERSION=edge/main/dev can't clobber the stable latest pointer (P1).
- run-docker-full-stack.sh: stop deriving OBJECT_STORAGE_PUBLIC_ENDPOINT from
  the dashboard origin (broke presigned URLs for videos/documents buckets);
  derive only the public media base URL and document the S3-API route.
- ci.yml: provide PUBLIC_IS_SELFHOSTED at build time (dashboard reads it from
  SvelteKit's strict $env/static/public); bump cache actions v3 -> v4.
- upload-openapi-spec.yml: bump cache actions v3 -> v4.
- smoke-test.sh: dashboard gate now fails on 5xx instead of accepting any response.

* ci: scope PR build gate to the api + dashboard closures

Building every package surfaced pre-existing rot in apps that aren't part of
this PR (storybook's missing @lucide/svelte import, course-app prettier debt).
The gate's purpose is to verify the two Docker-image apps; the rest are built on
PRs by their own Cloudflare Workers builds. Filter to @cio/api... and
@cio/dashboard... so the gate covers exactly what it should.

* fix(docker-push): make latest_arg always return 0

Use an `if` instead of `is_release_version && printf` so the helper returns 0
even when it emits no tag. A non-zero return inside the $(latest_arg ...) build
argument is fragile under set -e (and would abort with inherit_errexit), which
would break the edge/main builds the policy explicitly supports.

* fix(docker): copy patches/ into jobs image so pnpm install resolves patchedDependencies

The jobs Dockerfile ran `pnpm install` without copying patches/, so pnpm
failed hashing package.json#pnpm.patchedDependencies (layerchart patch),
breaking the self-host full-stack build. api/dashboard already copy patches/.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(docker): build jobs worker deps in topological order

The jobs Dockerfile built workspace packages with a hand-written list that
omitted transitive deps (e.g. @cio/question-types, consumed by @cio/utils),
so `tsc` failed with TS2307 during the self-host build. Use the recursive
`pnpm -r --filter @cio/jobs-worker... build` like the api/dashboard images,
which builds the full dependency closure in the correct order.

* refactor(docker): move jobs Dockerfile to docker/Dockerfile.jobs and align dep install

Move apps/jobs/Dockerfile -> docker/Dockerfile.jobs so all three image recipes
live together in docker/, and update every reference (docker-compose.yaml,
docker-push.sh, docker-publish.yml, railway.jobs.json, self-hosting.mdx).

Also align its dependency install with the api/dashboard images: replace the
hand-maintained list of workspace package.json copies (which silently missed
transitive deps like @cio/core / @cio/ai-assistant, leaving their node_modules
uninstalled and breaking the build with TS2307) with the proven pnpm-fetch
pattern: fetch the full lockfile into a cached store, COPY . ., then an offline
filtered install of @cio/jobs-worker... This installs the complete dependency
closure regardless of how the workspace graph grows.

* refactor(docker): move docker-compose.yaml to repo root for Coolify compatibility

Coolify's Docker Compose build pack forces Base Directory to the repo root and
resolves build contexts from there, so the compose's `context: ..` (needed when
the file lived in docker/) climbed above the repo and failed with
`lstat /artifacts/docker: no such file or directory`.

Move docker/docker-compose.yaml -> ./docker-compose.yaml and change the build
contexts from `..` to `.` so the context is the repo root with no upward path.
This works identically for run-docker-full-stack.sh, manual `docker compose up`,
and Coolify. Dockerfiles stay referenced as docker/Dockerfile.*. Update the
references in run-docker-full-stack.sh and the docs accordingly. The images
compose (docker-compose.images.yaml) is unaffected (no build context) and stays
in docker/.

* Add self-hosting documentation for Coolify and Railway

- Created new documentation for deploying ClassroomIO on Coolify using a pull-based Docker Compose stack.
- Added comprehensive guide for self-hosting ClassroomIO with Docker, covering both pre-built images and building from source.
- Introduced a new meta.json file to organize self-hosting documentation pages.
- Developed a Railway deployment guide with environment variable configurations for API, dashboard, and jobs worker.
- Updated self-hosting blog post to link directly to the new Railway guide.
- Removed obsolete Railway configuration files (railway.api.json, railway.dashboard.json, railway.jobs.json).
- Enhanced run-docker-full-stack.sh script to improve MinIO credential generation and service readiness checks.
- Updated docker-compose files to enforce strong secret requirements for authentication.
- Adjusted self-hosting references in the Docker documentation to include new guides.

* fix(docker): point references at root-level docker-compose.images.yaml

The pull-based compose file moved to the repo root (alongside docker-compose.yaml).
Update IMAGES_COMPOSE_FILE in run-docker-full-stack.sh, the file's own header
example, and the docs (docker.mdx curl URL, coolify.mdx path + blob link,
SELF_HOST.md relative link + curl URL).

* fix(self-host): address review round 2 — drop unused AUTH_BEARER_TOKEN, pull-by-default, doc fixes

- Remove AUTH_BEARER_TOKEN everywhere (unused in app code; PRIVATE_SERVER_KEY is the real
  dashboard<->api key). Simplifies run-docker-full-stack.sh secret generation.
- run-docker-full-stack.sh now defaults to PULLING pre-built images; add --build for source.
- docs: recommend MinIO, 'a way' (not 'recommended way') to self-host, remove Direct API Access
  section, trim Common Commands, SMTP vars per-line, fix PRIVATE_SERVER_KEY wording.

* feat(db): versioned migrations for self-host with auto-baseline

- Regenerate a single clean baseline migration; db:setup now runs drizzle-kit migrate (not push).
- Add db:generate / db:migrate / db:baseline scripts.
- baselineMigrationsIfNeeded(): adopt an existing (push/supabase) schema into drizzle's journal so
  migrate won't try to recreate existing objects ("type/table already exists"). No-ops on fresh
  and already-tracked databases; runs automatically in db:setup before migrate.

* ci(openapi): drop stale ci.yml reference in turbo-cache comment

ci.yml was removed earlier in this review; the openapi workflow's turbo cache still
warms from its own prior runs via restore-keys, so this is a comment-only fix.

* fix(db): make baseline squash-safe for migrate-tracked databases

baselineMigrationsIfNeeded returned early whenever drizzle.__drizzle_migrations
had any rows. After a migration squash, an instance tracked under the old
journal (pre-squash hashes) would then let `drizzle-kit migrate` re-run the
consolidated baseline — whose journal `when` is newer than every old created_at
— and crash on `CREATE TYPE/TABLE "..." already exists` at startup.

Now, when history exists but the consolidated baseline hash is absent, insert
that hash (marking it already applied) so migrate skips it. Only the baseline
entry (idx 0) is reconciled, so genuinely pending future migrations still run.

Verified end-to-end against Postgres: reproduces the original crash, confirms
the fix no-ops migrate on a pre-squash DB, and confirms a later 0001 migration
still applies. Fresh-DB and already-adopted paths unchanged.

* fix(docker): add explicit id to pnpm-store cache mounts

Railway's Metal builder rejects BuildKit cache mounts that omit an
explicit id (it does not default id to target like standard Docker/
BuildKit do), failing the build at parse time with:

  dockerfile invalid: flag '--mount=type=cache,target=/pnpm/store'
  is missing an id argument

Add id=pnpm-store to all six cache mounts across the api, dashboard,
and jobs images. Harmless on Docker Desktop / CI / GitHub Actions, and
required for the Railway self-host build path.

* fix(docker): drop BuildKit cache mounts for Railway compatibility

Railway's builder rejects cache-mount ids not prefixed with its
per-service cacheKey, which can't be hardcoded in a Dockerfile shared
across CI, local Docker, and three Railway services. Remove the
--mount=type=cache flag (keep --store-dir /pnpm/store); the store is
baked into the fetch layer and reused by the offline install, so
behaviour is unchanged. Supersedes 9c4633f2c.

* fix(docker): force LF for shell scripts and Dockerfiles via .gitattributes

cio-api crash-looped on Railway with 'exec /app/entrypoint.sh: No such
file or directory' because the entrypoint shipped with CRLF endings
(Windows checkout, core.autocrlf=true) — the '#!/bin/sh\r' shebang is
unresolvable in the container. Add .gitattributes pinning *.sh and
Dockerfile* to LF so container-executed files are never CRLF regardless
of contributor OS.

* fix(db): serialize db-setup with an advisory lock to prevent baseline double-insert

Concurrent db:setup runs (e.g. multiple API replicas booting together) could both
pass the baseline 'already present?' check and insert duplicate rows into
drizzle.__drizzle_migrations, which has no unique constraint on hash. Wrap baseline
+ migrate in a pg_advisory_lock held on a dedicated connection so concurrent
startups serialize: the loser blocks, then sees the baseline applied and no-ops.

* docs(railway): update template deploy URL to classroomio-1

---------
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Abdulmuizz Hamzat
2026-06-23 10:17:09 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 46acad8052
commit 6d594b4296
44 changed files with 12142 additions and 15284 deletions
@@ -3,7 +3,7 @@ title: Cloudflare Setup
description: Guide to setting up Cloudflare for ClassroomIO.
---
In this guide, you will learn how to get your Cloudflare environment variables and configure R2 buckets. ClassroomIO supports Cloudflare R2 for video uploads, document uploads, and media (images and video thumbnails). For on-premise or data-residency deployments, you can use [MinIO](/docs/quickstart/self-hosting#option-b-minio-s3-compatible-on-premise) instead.
In this guide, you will learn how to get your Cloudflare environment variables and configure R2 buckets. ClassroomIO supports Cloudflare R2 for video uploads, document uploads, and media (images and video thumbnails). For on-premise or data-residency deployments, you can use [MinIO](/docs/self-hosted/docker#optional--object-storage) instead.
## R2 buckets overview
+4 -1
View File
@@ -49,6 +49,9 @@
"roles-and-permissions",
"---Contributor guides---",
"contributor-guides"
"contributor-guides",
"---Self Hosting---",
"self-hosted"
]
}
+1 -2
View File
@@ -1,5 +1,4 @@
{
"signup": "Sign Up",
"onboarding": "Onboarding",
"self-hosting": "Self-hosting"
"onboarding": "Onboarding"
}
@@ -1,263 +0,0 @@
---
title: Self-hosting ClassroomIO
description: Guide to self-hosting ClassroomIO on your own servers.
---
import { Callout } from 'fumadocs-ui/components/callout';
This guide covers two deployment paths: **Docker** (recommended) and **Railway**.
![Selfhost ClassroomIO](/selfhost-classroomio.webp)
## Docker (Recommended)
### Prerequisites
- Docker Desktop installed and running
- `git` installed
### Quick Start
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` and `BETTER_AUTH_SECRET` for your domain. For local development the defaults work as-is.
3. Start the full stack:
```zsh title="Terminal"
./run-docker-full-stack.sh
```
The script builds and starts Postgres, Redis, MinIO (object storage), the API, and the dashboard. It auto-generates secure values for `AUTH_BEARER_TOKEN` and `PRIVATE_SERVER_KEY` when they are missing.
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 (default login: `minioadmin` / `minioadmin`)
### 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.
### Environment Variables
#### Required
| Variable | Description | Local Default |
|----------|-------------|---------------|
| `POSTGRES_DB` | Database name | `classroomio` |
| `POSTGRES_USER` | Database user | `postgres` |
| `POSTGRES_PASSWORD` | Database password | `postgres` |
| `BETTER_AUTH_SECRET` | Auth session signing secret | — |
| `AUTH_BEARER_TOKEN` | API bearer token | Auto-generated |
| `PRIVATE_SERVER_KEY` | Dashboard-to-API auth key (same in both) | Auto-generated |
| `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` |
| `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` |
| `DASHBOARD_ORIGIN` | Dashboard origin for invite links, email links, etc. | `http://localhost:3082` |
#### Optional — Direct API Access
Dashboard browser requests use same-origin `/proxy/*` and `/api/auth/*` paths, then the dashboard forwards them to `PRIVATE_SERVER_URL`. Set these only when browsers or third-party clients need to call the API origin directly.
| Variable | Description | Local Default |
|----------|-------------|---------------|
| `PUBLIC_SERVER_URL` | Canonical public API URL / direct API fallback | `http://localhost:3081` |
| `TRUSTED_ORIGINS` | Comma-separated CORS origins for direct browser-to-API calls | — |
#### CSP Domains (Runtime)
CSP domain variables are **runtime** — set them in your `.env` or `docker-compose.yaml` `environment:` section. No image rebuild is needed when changing them.
| Variable | Description |
|----------|-------------|
| `ALLOWED_EXTERNAL_DOMAINS` | Overrides all per-directive CSP vars (comma-separated) |
| `CSP_SCRIPT_SRC_DOMAINS` | Domains allowed in `script-src` |
| `CSP_STYLE_SRC_DOMAINS` | Domains allowed in `style-src` |
| `CSP_CONNECT_SRC_DOMAINS` | Domains allowed in `connect-src` |
| `CSP_FRAME_SRC_DOMAINS` | Domains allowed in `frame-src` |
| `CSP_FONT_SRC_DOMAINS` | Domains allowed in `font-src` |
| `CSP_MEDIA_SRC_DOMAINS` | Domains allowed in `img-src` and `media-src` |
<Callout type="warn" title="CSP Domains">
When `PUBLIC_IS_SELFHOSTED=true`, the dashboard starts with only `'self'` allowed. Set `ALLOWED_EXTERNAL_DOMAINS` (or the per-directive vars) with your CDN, video, and font domains. The API does not need to be added for normal dashboard traffic because requests are proxied same-origin. Since these are runtime env vars, you can change them by restarting the container — no rebuild required.
</Callout>
#### Optional — Email
Configure either SMTP or Zoho. Without email config, the app works but cannot send emails.
| Variable | Description |
|----------|-------------|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SENDER` | SMTP server |
| `ZOHO_TOKEN` | Zoho/ZeptoMail token (alternative to SMTP) |
#### Optional — Object Storage
MinIO is included by default. The startup script auto-configures these — you only need to change them if using custom credentials or an external S3-compatible service.
| Variable | Default |
|----------|---------|
| `MINIO_ROOT_USER` | `minioadmin` |
| `MINIO_ROOT_PASSWORD` | `minioadmin` |
| `OBJECT_STORAGE_ENDPOINT` | `http://minio:9000` |
| `OBJECT_STORAGE_PUBLIC_ENDPOINT` | `http://localhost:9000` |
| `OBJECT_STORAGE_ACCESS_KEY_ID` | `minioadmin` |
| `OBJECT_STORAGE_SECRET_ACCESS_KEY` | `minioadmin` |
| `OBJECT_STORAGE_FORCE_PATH_STYLE` | `true` |
| `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` | `http://localhost:9000/media` |
Use `./run-docker-full-stack.sh --no-minio` to exclude MinIO if you do not need uploads.
<Callout type="error" title="Change default credentials">
Do not use `minioadmin` / `minioadmin` in production.
</Callout>
#### Optional — Integrations
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google OAuth |
| `PRIVATE_APP_HOST`, `PRIVATE_APP_SUBDOMAINS` | Multi-subdomain routing |
| `UNSPLASH_API_KEY` | Unsplash image search |
| `LICENSE_KEY` | Enterprise features (SSO, token-auth, no-tracking) |
| `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` | Custom branding |
### Common Commands
```zsh title="Terminal"
# Start full stack (builds images, starts all services including MinIO)
./run-docker-full-stack.sh
# Start without rebuilding images
./run-docker-full-stack.sh --no-build
# Start without MinIO (no file uploads)
./run-docker-full-stack.sh --no-minio
# Service status
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml ps
# Stream logs
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml logs -f api dashboard
# Rebuild one service
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml up -d --build api
# Restart after env change (no rebuild needed)
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml restart api dashboard
# Run DB seed
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml run --rm --entrypoint sh api -c "pnpm --filter @cio/db db:setup -- --seed"
# Stop
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml down
# Stop and delete all data (database, redis, minio volumes)
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml down -v
```
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
```
<Callout type="warn" title="Volume prune deletes data">
Avoid `docker volume prune` after `docker compose ... down` — it can remove your database and storage volumes.
</Callout>
Then restart: `./run-docker-full-stack.sh`
---
## Railway
The quickest hosted deployment is the [Railway Template](https://railway.app/template/dl9RG7?referralCode=8OyIj5).
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/dl9RG7?referralCode=8OyIj5)
### cio-api
```zsh title="env"
DATABASE_URL="${{db.DATABASE_URL}}"
REDIS_URL="${{Redis.REDIS_URL}}"
BETTER_AUTH_SECRET="<generate-with-openssl-rand-hex-32>"
AUTH_BEARER_TOKEN="<generate-with-openssl-rand-hex-32>"
PRIVATE_SERVER_KEY="<generate-with-openssl-rand-hex-32>" # must be identical in cio-api and cio-dashboard
PUBLIC_IS_SELFHOSTED="true"
# Dashboard URL for invite/email links (e.g. https://app.yourdomain.com)
DASHBOARD_ORIGIN="https://app.yourdomain.com"
# Optional direct public API URL. Normal dashboard auth/API calls use the
# dashboard proxy and do not require API/dashboard cookie-domain matching.
PUBLIC_SERVER_URL="https://api.yourdomain.com"
TRUSTED_ORIGINS=
# Email (configure one)
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
SMTP_SENDER=
# Object storage — Cloudflare R2 (for hosted deployments)
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_ACCESS_KEY=
CLOUDFLARE_SECRET_ACCESS_KEY=
CLOUDFLARE_BUCKET_DOMAIN=
CLOUDFLARE_IMAGE_BUCKET_DOMAIN=
```
Optional: add `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` for OAuth, `UNSPLASH_API_KEY` for image search, or `LICENSE_KEY` for enterprise features.
### cio-dashboard
```zsh title="env"
ALLOWED_EXTERNAL_DOMAINS="https://*.yourdomain.com,https://fonts.googleapis.com"
PRIVATE_APP_HOST="https://app.yourdomain.com"
PRIVATE_SERVER_KEY="same-key-for-api-and-dashboard"
PUBLIC_IS_SELFHOSTED="true"
PRIVATE_SERVER_URL="http://${{api.RAILWAY_PRIVATE_DOMAIN}}:8080"
ORIGIN="https://app.yourdomain.com"
```
Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. You can also use per-directive CSP vars (`CSP_SCRIPT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`, etc.) instead of `ALLOWED_EXTERNAL_DOMAINS` for finer control.
<Callout title="PRIVATE_SERVER_KEY">
`PRIVATE_SERVER_KEY` must be the same value in both API and dashboard. It authenticates server-side dashboard requests to the API.
</Callout>
### SMTP Setup
You need SMTP for email sending. See [Nodemailer SMTP docs](https://nodemailer.com/smtp/) for provider setup.
### Cloudflare R2
For hosted deployments, Cloudflare R2 handles object storage. See the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide.
### SSO (Enterprise)
SSO requires a license key. Set `LICENSE_KEY` in your API environment. [Book a call](http://cal.com/classroomio/enterprise) to get a license.
@@ -0,0 +1,58 @@
---
title: Coolify
description: Deploy ClassroomIO on Coolify using the pull-based Docker Compose stack.
---
import { Callout } from 'fumadocs-ui/components/callout';
[Coolify](https://coolify.io) is a self-hosted PaaS (an open-source Heroku/Netlify alternative) you run on your own VPS. It deploys ClassroomIO as a **Docker Compose resource** using the pull-based [`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml) — so your Coolify host **pulls** the published `classroomio/*` images and never builds anything.
## Prerequisites
- A running Coolify instance with a connected server.
- A domain pointed at that server (Coolify's built-in proxy terminates HTTPS for you).
## Steps
1. **New Resource → Docker Compose.** Point Coolify at this repo and the compose path `docker-compose.images.yaml`, or paste the file's contents.
2. **Set environment variables** (Coolify's UI — these become the compose `.env`). Coolify does **not** run our `run-docker-full-stack.sh`, so generate secrets yourself:
```zsh title="Required"
CIO_VERSION=1.4.2 # 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 services
POSTGRES_PASSWORD=<strong password>
# Email (required for signup/reset/invites)
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
SMTP_SENDER=
```
<Callout type="error" title="The stack won't start without strong secrets">
`BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` are required. The compose file fails fast if
either is empty, so it never boots with a predictable session-signing secret.
</Callout>
3. **Assign a domain.** Map your domain to the **dashboard** service (internal port `3082`); Coolify's reverse proxy issues the certificate. The dashboard proxies API calls server-side, so you normally only need the dashboard exposed publicly — the `api` service stays internal.
4. **Object storage.** Recommended: the **bundled MinIO** — enable its profile with
`COMPOSE_PROFILES=minio` and Coolify runs MinIO alongside the stack. Give the MinIO volume
persistent storage in Coolify and route its public URL (`OBJECT_STORAGE_PUBLIC_ENDPOINT` /
`OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`) so browsers can load media. Prefer fully-managed
storage instead? Point the same `OBJECT_STORAGE_*` variables at external **S3 / Cloudflare R2**
and leave the `minio` profile off — see the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
5. **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.
## Upgrades & rollback
Bump `CIO_VERSION` to a newer release and redeploy; roll back by setting it to the previous version and redeploying. Because you pin a version, a new release never restarts you onto broken images unattended.
<Callout type="warn" title="Back up before upgrading">
Schema migrations run automatically on API startup. Back up your Postgres (and object storage)
before a major upgrade.
</Callout>
@@ -0,0 +1,241 @@
---
title: Docker
description: Self-host ClassroomIO with Docker — pull pre-built images (recommended) or build from source.
---
import { Callout } from 'fumadocs-ui/components/callout';
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).
![Selfhost ClassroomIO](/selfhost-classroomio.webp)
## Run from Pre-built Images (recommended)
### Prerequisites
- Docker installed and running.
### Steps
1. Download the pull-based compose file and an env template:
```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
```
2. Edit `.env`. At minimum set `CIO_VERSION` (pin a release), `DASHBOARD_ORIGIN`, the auth secrets, and SMTP:
```zsh title=".env"
CIO_VERSION=1.4.2 # 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
POSTGRES_PASSWORD=<strong password>
# Email (required for signup/reset/invites)
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
SMTP_SENDER=
```
<Callout type="error" title="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 — e.g. `openssl rand -hex 32`. The
compose file fails fast with a clear error if either is empty, so it never boots with a
predictable session-signing secret.
</Callout>
3. Pull and start the stack (MinIO is included via the `minio` profile):
```zsh title="Terminal"
docker compose -f docker-compose.images.yaml --env-file .env --profile minio pull
docker compose -f docker-compose.images.yaml --env-file .env --profile minio up -d
```
4. Verify:
```zsh title="Terminal"
curl -sS http://localhost:3081/ # API — returns JSON
curl -I http://localhost:3082/ # Dashboard — returns 200
```
- Dashboard: http://localhost:3082
- API: http://localhost:3081
- MinIO Console: http://localhost:9001 (log in with `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` from your `.env`)
## Build from Source
Use this if you want to build the images locally (e.g. to run code changes).
### Prerequisites
- Docker Desktop installed and running
- `git` installed
- ~8 GB RAM available for the dashboard build
### Steps
1. Clone the repository:
```zsh title="Terminal"
git clone https://github.com/classroomio/classroomio.git
cd classroomio
```
2. Copy the env template and edit it:
```zsh title="Terminal"
cp .env.example .env
```
Open `.env` and set `DASHBOARD_ORIGIN` for your domain. For local development the defaults work as-is — the startup script generates `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` when they are missing, and randomizes the MinIO credentials.
3. Build and start the full stack:
```zsh title="Terminal"
./run-docker-full-stack.sh --build
```
The script builds and starts Postgres, Redis, MinIO (object storage), the API, the dashboard, and the background **jobs** worker.
<Callout title="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.
</Callout>
## Upgrading to a New Version
ClassroomIO publishes versioned images. `CIO_VERSION` controls which tag you run:
- Pin an exact release (e.g. `1.4.2`) in production so a new release never restarts you onto changed images unattended.
- `latest` = newest stable release. `edge` = bleeding edge (last `main` build).
**Pre-built images path** — bump `CIO_VERSION` in `.env`, then re-pull and recreate:
```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
```
**Rollback** — set `CIO_VERSION` back to the previous release and run the same two commands.
**Build-from-source path** — `git pull`, then re-run `./run-docker-full-stack.sh --build` to rebuild.
<Callout type="warn" title="Back up before upgrading">
Database schema migrations run automatically when the API starts. Back up your Postgres volume
(and object storage) before a major upgrade, and avoid `docker compose down -v` / `docker volume
prune`, which delete your data volumes.
</Callout>
## 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.
## Environment Variables
### Required
| Variable | Description | Default |
|----------|-------------|---------|
| `POSTGRES_DB` | Database name | `classroomio` |
| `POSTGRES_USER` | Database user | `postgres` |
| `POSTGRES_PASSWORD` | Database password | `postgres` |
| `BETTER_AUTH_SECRET` | Auth session signing secret (required — stack won't start if empty) | — |
| `PRIVATE_SERVER_KEY` | Dashboard-to-API auth key (must be identical in api + dashboard) | Auto-generated by the script |
| `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` |
| `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` |
| `DASHBOARD_ORIGIN` | Dashboard origin for invite links, email links, etc. | `http://localhost:3082` |
### CSP Domains (Runtime)
CSP domain variables are **runtime** — set them in your `.env`. No image rebuild is needed when changing them.
| Variable | Description |
|----------|-------------|
| `ALLOWED_EXTERNAL_DOMAINS` | Overrides all per-directive CSP vars (comma-separated) |
| `CSP_SCRIPT_SRC_DOMAINS` | Domains allowed in `script-src` |
| `CSP_STYLE_SRC_DOMAINS` | Domains allowed in `style-src` |
| `CSP_CONNECT_SRC_DOMAINS` | Domains allowed in `connect-src` |
| `CSP_FRAME_SRC_DOMAINS` | Domains allowed in `frame-src` |
| `CSP_FONT_SRC_DOMAINS` | Domains allowed in `font-src` |
| `CSP_MEDIA_SRC_DOMAINS` | Domains allowed in `img-src` and `media-src` |
<Callout type="warn" title="CSP Domains">
When `PUBLIC_IS_SELFHOSTED=true`, the dashboard starts with only `'self'` allowed. Set `ALLOWED_EXTERNAL_DOMAINS` (or the per-directive vars) with your CDN, video, and font domains. The API does not need to be added for normal dashboard traffic because requests are proxied same-origin. Since these are runtime env vars, you can change them by restarting the container — no rebuild required.
</Callout>
### Optional — Email
Configure SMTP. Without email config, the app works but cannot send invites, password resets, or notifications.
| Variable | Description |
|----------|-------------|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SENDER` | SMTP server |
### Optional — Object Storage
The bundled **MinIO** is included by default and the startup script auto-configures it — you only need to change these for custom credentials or an external S3-compatible service.
| Variable | Default |
|----------|---------|
| `MINIO_ROOT_USER` | randomized by the script (`minioadmin` if you skip it) |
| `MINIO_ROOT_PASSWORD` | randomized by the script (`minioadmin` if you skip it) |
| `OBJECT_STORAGE_ENDPOINT` | `http://minio:9000` |
| `OBJECT_STORAGE_PUBLIC_ENDPOINT` | `http://localhost:9000` |
| `OBJECT_STORAGE_ACCESS_KEY_ID` | matches `MINIO_ROOT_USER` |
| `OBJECT_STORAGE_SECRET_ACCESS_KEY` | matches `MINIO_ROOT_PASSWORD` |
| `OBJECT_STORAGE_FORCE_PATH_STYLE` | `true` |
| `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` | `http://localhost:9000/media` |
<Callout title="Using AWS S3 or Cloudflare R2 instead of MinIO">
The `OBJECT_STORAGE_*` variables speak the S3 API, so they work with any S3-compatible provider.
To use Cloudflare R2 or AWS S3, point `OBJECT_STORAGE_ENDPOINT` at the provider's endpoint and set
the matching access key / secret, then start without the bundled MinIO via
`./run-docker-full-stack.sh --no-minio`. See the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
</Callout>
### Optional — Integrations
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google OAuth |
| `PRIVATE_APP_HOST`, `PRIVATE_APP_SUBDOMAINS` | Multi-subdomain routing |
| `UNSPLASH_API_KEY` | Unsplash image search |
| `OPENAI_API_KEY` | Transcription + AI course generation (used by the jobs worker) |
| `LICENSE_KEY` | Enterprise features (SSO, token-auth, no-tracking) |
| `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` | Custom branding |
## 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
```
<Callout type="warn" title="Volume prune deletes data">
Avoid `docker volume prune` after `docker compose ... down` — it can remove your database and storage volumes.
</Callout>
Then restart: `./run-docker-full-stack.sh`
@@ -0,0 +1,4 @@
{
"title": "Self Hosting",
"pages": ["docker", "railway", "coolify"]
}
@@ -0,0 +1,139 @@
---
title: Railway
description: Deploy ClassroomIO on Railway with the one-click template.
---
import { Callout } from 'fumadocs-ui/components/callout';
The quickest hosted deployment is the [Railway Template](https://railway.com/deploy/classroomio-1?referralCode=8OyIj5). It provisions the API, dashboard, and jobs worker services plus the Postgres and Redis plugins for you.
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/classroomio-1?referralCode=8OyIj5)
After deploying the template, fill in the environment variables for each service below.
## cio-api
```zsh title="env"
DATABASE_URL="${{db.DATABASE_URL}}"
REDIS_URL="${{Redis.REDIS_URL}}"
BETTER_AUTH_SECRET="<generate-with-openssl-rand-hex-32>"
PRIVATE_SERVER_KEY="<generate-with-openssl-rand-hex-32>" # must be identical in cio-api and cio-dashboard
PUBLIC_IS_SELFHOSTED="true"
# Dashboard URL for invite/email links. Reference the dashboard service's Railway domain
# so it stays correct automatically. (For a custom domain, hardcode it: https://app.yourdomain.com)
DASHBOARD_ORIGIN="https://${{cio-dashboard.RAILWAY_PUBLIC_DOMAIN}}"
# Optional direct public API URL. Normal dashboard auth/API calls use the
# dashboard proxy and do not require API/dashboard cookie-domain matching.
PUBLIC_SERVER_URL="https://api.yourdomain.com"
TRUSTED_ORIGINS=
# Email (configure SMTP)
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
SMTP_SENDER=
# Object storage → the self-hosted cio-minio service (see below)
OBJECT_STORAGE_ENDPOINT=http://${{cio-minio.RAILWAY_PRIVATE_DOMAIN}}:9000
OBJECT_STORAGE_PUBLIC_ENDPOINT=https://storage.yourdomain.com
OBJECT_STORAGE_ACCESS_KEY_ID=${{cio-minio.MINIO_ROOT_USER}}
OBJECT_STORAGE_SECRET_ACCESS_KEY=${{cio-minio.MINIO_ROOT_PASSWORD}}
OBJECT_STORAGE_FORCE_PATH_STYLE=true
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=https://storage.yourdomain.com/media
```
Optional: add `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` for OAuth, `UNSPLASH_API_KEY` for image search, or `LICENSE_KEY` for enterprise features.
## cio-dashboard
```zsh title="env"
# RAILWAY_PUBLIC_DOMAIN here = this dashboard service's own public domain.
# For a custom domain, hardcode app.yourdomain.com in ORIGIN / PRIVATE_APP_HOST instead.
ORIGIN="https://${{RAILWAY_PUBLIC_DOMAIN}}"
PRIVATE_APP_HOST="${{RAILWAY_PUBLIC_DOMAIN}}"
PUBLIC_IS_SELFHOSTED="true"
PRIVATE_SERVER_URL="http://${{cio-api.RAILWAY_PRIVATE_DOMAIN}}:${{cio-api.PORT}}"
PRIVATE_SERVER_KEY="${{cio-api.PRIVATE_SERVER_KEY}}" # same value as cio-api
ALLOWED_EXTERNAL_DOMAINS="https://fonts.googleapis.com"
```
Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. You can also use per-directive CSP vars (`CSP_SCRIPT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`, etc.) instead of `ALLOWED_EXTERNAL_DOMAINS` for finer control.
## 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:
```zsh title="env"
DATABASE_URL="${{db.DATABASE_URL}}"
REDIS_URL="${{Redis.REDIS_URL}}"
# Object storage — same cio-minio service as the API
OBJECT_STORAGE_ENDPOINT=http://${{cio-minio.RAILWAY_PRIVATE_DOMAIN}}:9000
OBJECT_STORAGE_PUBLIC_ENDPOINT=https://storage.yourdomain.com
OBJECT_STORAGE_ACCESS_KEY_ID=${{cio-minio.MINIO_ROOT_USER}}
OBJECT_STORAGE_SECRET_ACCESS_KEY=${{cio-minio.MINIO_ROOT_PASSWORD}}
OBJECT_STORAGE_FORCE_PATH_STYLE=true
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=https://storage.yourdomain.com/media
# Email (worker sends invites, notifications, etc.)
SMTP_HOST= SMTP_PORT= SMTP_USER= SMTP_PASSWORD= SMTP_SENDER=
# Transcription + OpenAI-backed AI course generation
OPENAI_API_KEY=
```
<Callout title="PRIVATE_SERVER_KEY">
`PRIVATE_SERVER_KEY` must be the same value in both API and dashboard. It authenticates server-side dashboard requests to the API. Referencing `${{cio-api.PRIVATE_SERVER_KEY}}` in the dashboard keeps them in sync.
</Callout>
## cio-minio (object storage)
We recommend a self-hosted **MinIO** service for object storage. Add it as a new service from
the Docker image `minio/minio:latest`:
- **Start command:** `server /data --console-address ":9001"`
- **Volume:** attach a Railway Volume mounted at **`/data`** — **required**, or every upload is
wiped on each redeploy.
- **Public domain:** e.g. `storage.yourdomain.com` with **target port `9000`** (the S3 API).
Railway also gives it a private domain (`cio-minio.railway.internal:9000`) for the api/jobs.
```zsh title="env"
MINIO_ROOT_USER=<strong user>
MINIO_ROOT_PASSWORD=<strong password>
# Optional (silences console redirect warnings behind the proxy):
MINIO_SERVER_URL=https://storage.yourdomain.com
```
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.
`OBJECT_STORAGE_FORCE_PATH_STYLE=true` is required for MinIO.
### Seed the buckets (one-time)
Railway has no `minio-init`, so create the buckets once with [`mc`](https://min.io/docs/minio/linux/reference/minio-mc.html)
(run locally against the public domain):
```zsh title="Terminal"
mc alias set cio https://storage.yourdomain.com "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
mc mb cio/videos cio/documents cio/media --ignore-existing
mc anonymous set download cio/media # media bucket = public read (images)
```
<Callout type="warn" title="MinIO is your durability boundary">
Single-node MinIO has no redundancy — the Railway Volume is the only thing protecting your
uploads. Back it up. If you'd rather not operate storage, use external **Cloudflare R2 / AWS S3**
instead (R2 has no egress fees): point the same `OBJECT_STORAGE_*` vars at the provider and skip
the cio-minio service. See the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
</Callout>
## SMTP Setup
You need SMTP for email sending. See [Nodemailer SMTP docs](https://nodemailer.com/smtp/) for provider setup.
## SSO (Enterprise)
SSO requires a license key. Set `LICENSE_KEY` in your API environment. [Book a call](http://cal.com/classroomio/enterprise) to get a license.