Files
classroomio/docker/docs/SELF_HOST.md
T
Abdulmuizz Hamzat 0dc32b5210 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
2026-08-11 13:33:20 +01:00

14 KiB

ClassroomIO Docker Self-Hosting

Prerequisites

  • Docker Desktop installed and running
  • git installed

Quick Start

git clone https://github.com/classroomio/classroomio.git
cd classroomio
cp .env.example .env
# Edit .env — set your dashboard domain and secrets
./classroomio.sh start

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; 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 below.

Run from Pre-built Images (no build)

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 and a .env:

# 1. Get just the compose file + env template (no full clone required)
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 — set CIO_VERSION (pin a release!), DASHBOARD_ORIGIN, secrets, SMTP
# 3. Pull and start
CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env pull
CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env up -d

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:

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

Published images use a rolling latest + pinned releases tag policy:

Tag Meaning Use it for
1.4.2 (exact) An immutable, smoke-tested release Production — always pin this
1.4, 1 Latest patch/minor of that line Tracking a line
latest The freshest main build (rolling; may include unreleased code) Trying the newest code

latest moves on every merge to main, so it can change under you. Pin CIO_VERSION to an exact version in production so a merge can't break you unattended.

# Upgrade: bump the version, pull, recreate
CIO_VERSION=1.5.0 docker compose -f docker-compose.images.yaml --env-file .env pull
CIO_VERSION=1.5.0 docker compose -f docker-compose.images.yaml --env-file .env up -d

# Rollback: set CIO_VERSION back to the previous release and repeat

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, 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 PUBLISHING_IMAGES.md.)

Environment Variables

All Docker services read from a single root .env file. See .env.example for the full annotated template with required/optional grouping.

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). Everything else below has a sane default.

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 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). 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), set the same values on both api and dashboard, and raise your reverse-proxy body-size limit to match.

Verify

docker compose --env-file .env -p classroomio -f docker-compose.yaml ps
curl -sS http://localhost:3081/    # API — returns JSON
curl -I  http://localhost:3082/    # Dashboard — returns 200

Seed Demo Data

Schema setup runs automatically on api startup. To additionally seed demo data, run the seed script inside the running api container:

docker exec cio-api pnpm --filter @cio/db db:setup:seed

Command Reference

All commands use this prefix, abbreviated as dc below:

dc = docker compose --env-file .env -p classroomio -f docker-compose.yaml
Task Command
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 (recreates containers, applies .env changes) ./classroomio.sh restart
Stop dc down
Stop + delete volumes dc down -v

Build Images Locally

docker build -f docker/Dockerfile.api -t classroomio/api:latest .
docker build -f docker/Dockerfile.dashboard -t classroomio/dashboard:latest .

Inspect Container Env Vars

docker exec cio-api env | sort
docker exec cio-dashboard env | sort

Container names: cio-postgres, cio-redis, cio-api, cio-dashboard, cio-jobs, cio-minio.

MinIO (Object Storage)

MinIO is included by default. The startup script auto-configures env vars, starts MinIO, and creates videos, documents, and media buckets.

  • API endpoint: http://localhost:9000
  • Web UI: http://localhost:9001 (default: minioadmin / minioadmin)

To start MinIO manually (e.g. after --no-minio):

docker compose -f docker-compose.yaml --profile minio up -d
  • Web UI: http://localhost:9001 (credentials are the randomized MINIO_ROOT_USER / MINIO_ROOT_PASSWORD written to your .env).

Security: The startup script randomizes MINIO_ROOT_USER / MINIO_ROOT_PASSWORD on first provision and mirrors them into OBJECT_STORAGE_ACCESS_KEY_ID / OBJECT_STORAGE_SECRET_ACCESS_KEY. If you change them, keep both pairs in sync.

MinIO Web UI Not Loading

If port 9001 does not respond, recreate the container:

docker rm -f cio-minio
docker compose -f docker-compose.yaml --profile minio up -d

Data is preserved in the minio-data volume.

Background Jobs Worker

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

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.

docker compose --env-file .env -p classroomio -f docker-compose.yaml logs -f jobs

Note: the in-course AI tutor chat runs synchronously inside the API and does not need the worker. Course generation, transcription, and media processing do.

Email

Email is effectively required: signup verification and password reset are sent by the API, and invites/notifications are sent by the worker. Configure one of:

  • SMTP: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SENDER
  • Zoho / ZeptoMail: ZOHO_TOKEN

Both the api and jobs services read these. Without them, auth and invite flows appear to succeed in the UI but no email is delivered (ECONNREFUSED in logs).

Production Object Storage

The bundled MinIO is fine for a single host. Two things matter for a real deployment:

  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 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 OBJECT_STORAGE_* variables (endpoint, public endpoint, access key, secret, buckets) to your provider. The startup script fails fast if --no-minio is used without an OBJECT_STORAGE_ENDPOINT, so you cannot accidentally launch with no storage.

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 data. Back up regularly:

# Database (most important)
docker exec cio-postgres pg_dump -U postgres classroomio > backup-$(date +%F).sql

# Object storage — copy the minio-data volume, or use `mc mirror` to an off-box bucket

Restore the database into a fresh stack with psql/pg_restore against cio-postgres.

Scaling & Repeated Startups

db:setup runs on every api container start (idempotent schema sync). If you run multiple 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.

HLS Video Playback

Signed HLS playback (HLS_SIGNING_SECRET) is validated by the tenant-router Cloudflare Worker, which is not part of this compose stack. Self-hosted deployments serve media via the object storage public URL above; signed-HLS delivery is out of scope for the compose setup.

Rebuild After Code Changes

This setup builds images from source (no live hot reload). Code changes require a rebuild:

# One service
docker compose --env-file .env -p classroomio -f docker-compose.yaml up -d --build api

# Both (e.g. shared packages/* changed)
docker compose --env-file .env -p classroomio -f docker-compose.yaml up -d --build api dashboard

# Full stack (backs up first)
./classroomio.sh upgrade --build

Troubleshooting

"No space left on device"

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

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: ./classroomio.sh start

"The database system is in recovery mode" (57P03)

Postgres has not finished starting. Wait 30-60 seconds and retry. The compose healthcheck requires a successful SELECT 1 before dependent services start.

MinIO "Storage reached its minimum free drive threshold"

Same root cause as disk full. Free space using the steps above and restart MinIO:

docker compose --env-file .env -p classroomio -f docker-compose.yaml restart minio

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.

Port Conflicts

lsof -nP -iTCP:3081 -sTCP:LISTEN
lsof -nP -iTCP:3082 -sTCP:LISTEN