Files
classroomio/DEV_SETUP_NOTES.md
Abdulmuizz HamzatandRotimi Best 6d594b4296 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 <noreply@anthropic.com>

* 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 <best@classroomio.com>
2026-06-23 10:17:09 +01:00

8.8 KiB

ClassroomIO — Local Dev Setup Guide

A practical guide to running classroomio locally for development, plus a troubleshooting reference for the errors you're likely to hit. Use it to set up a fresh machine or to onboard a new contributor.

Works on macOS, Linux, and Windows. Windows users: run the interactive commands in Git Bash (Unix commands like cp work there). The repo's package scripts are already cross-platform.


1. Prerequisites

Tool Version Required? Notes
Node.js 20.x (.nvmrc = v20.19.3) Required nvm install && nvm use
pnpm 10.x Required The package scripts call pnpm directly (e.g. build:clean runs pnpm run build) — npm/yarn are not supported substitutes. corepack enable then corepack prepare pnpm@latest --activate, or npm i -g pnpm
Docker (Desktop) latest Required Runs Postgres + Redis (and optionally MinIO). No local install of those needed.
Git latest Required Windows: includes Git Bash
ffmpeg / ffprobe latest Optional Only for media/thumbnail background jobs

2. Setup steps

Step 1 — Install dependencies

pnpm i

Step 2 — Create the .env files

Each app/package reads its own .env. Copy each .env.example to .env and fill the values below (host ports). The secret values are placeholders — generate each one with openssl rand -hex 32 rather than copying the example text. The two PRIVATE_SERVER_KEY values must match, so generate it once and paste the same value into both files.

apps/api/.env

DATABASE_URL="postgresql://postgres:postgres@localhost:5432/classroomio"
REDIS_URL="redis://localhost:6379"
PUBLIC_SERVER_URL="http://localhost:3002"
TRUSTED_ORIGINS="http://localhost:5173"        # the dashboard dev origin
BETTER_AUTH_SECRET="<generate-with-openssl-rand-hex-32>"
PRIVATE_SERVER_KEY="<generate-with-openssl-rand-hex-32>"   # generate once; use the SAME value in the dashboard

apps/dashboard/.env

PUBLIC_SERVER_URL="http://localhost:3002"
PRIVATE_SERVER_URL="http://localhost:3002"      # used by the SSR auth proxy — required for login
PRIVATE_SERVER_KEY="<generate-with-openssl-rand-hex-32>"   # must match the API (paste the value generated above)
PUBLIC_IS_SELFHOSTED=true

apps/jobs/.env — only if you'll run the background workers (pnpm api:dev starts them). Simplest: copy the API env, which already has REDIS_URL + DATABASE_URL.

cp apps/api/.env apps/jobs/.env

packages/db/.env — used by the DB setup scripts (Step 4). Created there.

Why so many? The dashboard calls the API through a server-side proxy (/api/auth/*), so it needs PRIVATE_SERVER_URL. The API's better-auth needs BETTER_AUTH_SECRET (to sign sessions) and TRUSTED_ORIGINS (to trust the dashboard origin). Server-to-server data calls use the shared PRIVATE_SERVER_KEY.

Step 3 — Start infrastructure (Postgres + Redis)

docker compose -f docker-compose.yaml up -d postgres redis

(Optional — object storage for uploads: docker compose -f docker-compose.yaml --profile minio up -d minio minio-init.)

Step 4 — Set up + seed the database

cp packages/db/.env.example packages/db/.env     # contains DATABASE_URL for the db scripts
pnpm --filter @cio/db db:setup:seed              # creates schema + demo data

Step 5 — Build the shared packages once

pnpm build

The dashboard/api import workspace libs (@cio/ui, @cio/api, @cio/db, …) from their dist/, which only exists after a build. (If @cio/jobs-worker fails here on ffmpegProbeLuma, that's a known branch bug — see Known issues; it doesn't block dashboard/api.)

Step 6 — Run the app (two terminals)

pnpm api:dev          # API (port 3002) + background workers
pnpm dashboard:dev    # dashboard UI (port 5173)

Don't use pnpm dev — it tries to start the whole monorepo (~20 services) and currently trips a turbo concurrency limit. The two scoped commands above are the day-to-day workflow.

Step 7 — Log in

Open http://localhost:5173/login → admin@test.com / 123456.

Email (optional)

Email is off by default and the app runs fine without it. You only need it to test auth emails (verify/reset) and notifications.

To enable, set the SMTP vars in apps/api/.env (auth-flow emails) and apps/jobs/.env (all other emails) — both can point at the same server:

SMTP_HOST=  SMTP_PORT=  SMTP_USER=  SMTP_SENDER=  SMTP_PASSWORD=

Any SMTP provider works (Zoho ZeptoMail, Resend, Gmail SMTP, or Mailtrap for throwaway testing). See the inline notes in apps/api/.env.example for which emails each process sends.

Cost: most providers have a free tier for low volume; sustained/high volume is paid. For local dev a free sandbox (e.g. Mailtrap) or a provider's free tier is enough — developing costs nothing.


3. Ports

Service URL / Port Notes
API http://localhost:3002 backend (API_PORT default)
Dashboard http://localhost:5173 Vite dev server
Postgres localhost:5432 Docker container cio-postgres
Redis localhost:6379 Docker container cio-redis
MinIO 9000 / console 9001 only if started (optional)
jobs-worker (none) background worker, no HTTP port

4. Demo accounts

Seeded accounts use password 123456 (verified against the seed's bcrypt hashes):

  • admin@test.com / 123456 (admin — "Elon Gates")
  • enterprise@test.com, early-adopter@test.com, and the *-student@test.com accounts — also 123456

test@test.com's seed hash doesn't match common passwords — use admin@test.com.


5. Troubleshooting

Symptom Cause Fix
no such service: db-init db-init was removed; the api container now runs DB setup Don't reference db-init. For host dev use db:setup:seed (Step 4).
DATABASE_URL or PRIVATE_DATABASE_URL ... required packages/db/.env missing (db scripts read their own dir) cp packages/db/.env.example packages/db/.env
password authentication failed for user "postgres" localhost:5432 is another project's Postgres, not classroomio's Stop the other container / free port 5432, then recreate: docker compose -f docker-compose.yaml up -d postgres
Failed to resolve entry for package "@cio/*" shared packages not built (dist/ missing) pnpm build, then re-run the dev commands
'rm'/'cp' is not recognized or spawn EINVAL (Windows) Unix command / .cmd spawn in a script Already fixed in-repo (rimraf, Node copy, shell:true). Run pnpm i to get latest.
invalid task configuration … N persistent tasks … concurrency pnpm dev starts the whole monorepo past turbo's cap Use scoped pnpm api:dev + pnpm dashboard:dev instead
ZodError … REDIS_URL ... expected string, received undefined apps/jobs/.env missing REDIS_URL cp apps/api/.env apps/jobs/.env and set REDIS_URL=redis://localhost:6379
API upstream not configured (502) on login dashboard SSR proxy has no upstream Set PRIVATE_SERVER_URL=http://localhost:3002 in apps/dashboard/.env, restart dashboard
Login Invalid email or password wrong demo password Use admin@test.com / 123456
Login still fails after upstream fix API auth env empty Fill BETTER_AUTH_SECRET, TRUSTED_ORIGINS=http://localhost:5173, PUBLIC_SERVER_URL in apps/api/.env, restart API
Changed a .env but nothing changed env is read at process start Restart the affected *:dev server

After editing any .env, restart the relevant dev server.


6. Known issues (not setup problems)

  • ffmpegProbeLuma not exported — branch bug (commit 78ab13f54): apps/jobs/src/processors/media/generate-thumbnail.ts imports ffmpegProbeLuma but apps/jobs/src/utils/ffmpeg.ts doesn't export it. Crashes the two media workers only; API / dashboard / emails / maintenance are fine. For the thumbnail-feature owner.
  • ffmpeg/ffprobe not installed — media jobs need these binaries on PATH (or set FFMPEG_PATH / FFPROBE_PATH). Other workers are unaffected.
  • pnpm dev concurrency — full-stack run needs a turbo --concurrency bump; intentionally not changed. Use the scoped workflow.

7. Windows notes

  • Use Git Bash for interactive commands (cp, rm, etc.).
  • The repo's package scripts were made cross-platform (rimraf instead of rm -rf, a Node postinstall, a Node file-copy, and shell:true when scripts spawn pnpm), so pnpm i / pnpm build work in cmd.exe/PowerShell too.