Files
STCourses/docs/operations.md
T
Lucas WintherandClaude Opus 5.5 69aaedcb43 build: docker image and compose
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]>
2026-10-30 15:17:58 +01:00

86 lines
5.3 KiB
Markdown

# 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
```sh
# 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 in `apps/web/index.html` (no inline script).
A meta tag cannot set `frame-ancestors`; if you need clickjacking protection, add
`Content-Security-Policy: frame-ancestors 'self'` (or `X-Frame-Options: SAMEORIGIN`) in your reverse proxy.
- Course media (`/api/media/…`) is sent with `X-Content-Type-Options: nosniff`, and SVG, HTML and JavaScript files
with `Content-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.