Dockerfile on oven/bun:1.3 with ffmpeg, git, unzip and python3-pymupdf, a production install, non-root user, /data volume and a health check; docker-compose.yml (project stc) with an optional postgres:17 profile and read-only course and import mounts; docs/operations.md with configuration, mounts, security headers and the measured idle memory (101.4 MiB for app plus Postgres after 5 minutes idle). Co-Authored-By: Claude Opus 5.5 <[email protected]>
5.3 KiB
Operating STCourses
One server process (Bun) serves the API and the web app. Course content lives in folders; the database holds user data only (accounts, progress, notes, attempts, settings), so deleting the database loses user data but never a course.
Run with Docker Compose
# SQLite in the data volume (the default)
STC_SECRET="$(openssl rand -base64 48)" docker compose up -d
# Postgres instead
STC_SECRET="$(openssl rand -base64 48)" \
STC_DATABASE_URL=postgres://stc:stc@postgres:5432/stc \
docker compose --profile postgres up -d
The first account that signs up becomes the owner. Open http://localhost:3000 (or STC_HOST_PORT).
Mounts (all course mounts are read-only; STCourses never changes your originals):
| Host | Container | What |
|---|---|---|
volume stc-data |
/data |
database (SQLite), imported courses (/data/courses), studio repos (/data/studio/repos) and their published worktrees (/data/worktrees), caches (/data/cache), AI drafts for non-studio courses (/data/drafts) |
STC_COURSES_HOST (default ./courses) |
/courses |
course repos, one folder with a stcourse.yaml per course |
STC_IMPORT_HOST (default ./import-inbox) |
/import |
files the owner may import (Packt zips, video folders, PDFs, code zips) |
An overlay in overlays/ (baked into the image) names its course repo by absolute path in root:. Mount that repo
read-only at the same path inside the container, for example
/home/me/functional-programming-course:/home/me/functional-programming-course:ro. If the repo is missing, the
course shows as broken with a diagnostic and every other course keeps working.
Back up the stc-data volume (and the Postgres volume stc-pg when you use Postgres).
Configuration
| Variable | Default | Meaning |
|---|---|---|
STC_SECRET |
none (required in production) | encrypts users' own AI keys and signs sessions; use 32+ random characters |
STC_PUBLIC_URL |
http://localhost:<port> |
the URL users open; sign-in checks the request origin against it |
STC_PORT |
3000 |
listen port |
STC_DATABASE_URL |
sqlite://./data/stcourses.db (image: sqlite:///data/stcourses.db) |
sqlite://<path> or postgres://… |
STC_DATA_DIR |
./data (image: /data) |
see the table above |
STC_COURSE_DIRS |
./overlays:./courses (image: /app/overlays:/courses) |
:-separated folders scanned for courses; <data>/courses and <data>/worktrees are always added |
STC_IMPORT_ROOTS |
<data>/import-inbox (image: /import) |
:-separated folders the importer may read |
STC_UPLOAD_MAX_MB |
100 |
studio upload cap |
STC_PYTHON |
python3 |
Python with PyMuPDF, for PDF page images and text (installed in the image) |
STC_TRANSCRIBE_URL, STC_TRANSCRIBE_KEY, STC_TRANSCRIBE_MODEL |
unset | Whisper-compatible endpoint for video transcripts; unset disables transcription |
STC_AI_ALLOWED_BASE_URLS |
unset | ,-separated origins learners' own AI keys may use besides api.anthropic.com and api.openai.com |
STC_AUTH_<GITHUB/GOOGLE/MICROSOFT/GITLAB/DISCORD>_ID, _SECRET |
unset | social sign-in |
STC_AUTH_OIDC_ISSUER, _ID, _SECRET, _NAME, _SCOPES, _DISCOVERY_URL, STC_AUTH_GROUPS_CLAIM |
unset | generic OIDC sign-in; group claims drive restricted visibility |
STC_TRUSTED_ORIGINS |
unset | extra ,-separated origins allowed to call the sign-in API |
Without Docker: bun install, then STC_SECRET=… NODE_ENV=production bun apps/server/src/main.ts. The importer needs
ffmpeg, ffprobe, git, unzip and Python with PyMuPDF on the host.
Security headers
- The web app carries its Content-Security-Policy as a
<meta>tag inapps/web/index.html(no inline script). A meta tag cannot setframe-ancestors; if you need clickjacking protection, addContent-Security-Policy: frame-ancestors 'self'(orX-Frame-Options: SAMEORIGIN) in your reverse proxy. - Course media (
/api/media/…) is sent withX-Content-Type-Options: nosniff, and SVG, HTML and JavaScript files withContent-Security-Policy: sandbox, so author content cannot run script on the app's origin.
Memory budget
The deployment must stay under 2 GB idle. Measured with this compose file (--profile postgres), the image built
from this repository, and four courses mounted read-only: the sample course, Pure Snake and Systemic Design (both
overlays, repos mounted at their own paths), and the imported Packt course "Mastering Docker Essentials" (68 videos,
9 modules, a 59-slide deck, code files; about 800 MB of media). Before idling, one owner signed up and opened the
library, a video lesson with its deck, a Pure Snake lesson and the sample exercise in headless Chrome. Then nothing
for 5 minutes, and docker stats --no-stream:
| Container | Memory (idle, after 5 min) | CPU |
|---|---|---|
stc-app-1 (Bun server, SPA bundle built and cached) |
84.7 MiB | 1.00% |
stc-postgres-1 (postgres:17) |
16.67 MiB | 3.52% |
| Total | 101.4 MiB (about 5% of the 2 GB budget) |
Measured 2026-10-30 on Linux 6.12 (Docker Compose v5.5.1, oven/bun:1.3 = Bun 1.3.14). With SQLite there is
no Postgres container, so the total is the app's figure alone.
Video and PDF files are streamed from disk with Range requests; they never sit in memory. The SPA bundle is built once, on the first page request, and cached.