From 69aaedcb435c1981ecfc986cb10c7a4d405027ae Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 15:17:58 +0100 Subject: [PATCH] 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 --- .dockerignore | 13 +++++++ Dockerfile | 52 ++++++++++++++++++++++++++++ README.md | 5 +++ docker-compose.yml | 51 ++++++++++++++++++++++++++++ docs/operations.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/operations.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3e036ea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.worktrees +.superpowers +node_modules +**/node_modules +data +reports +research_notes +docs +**/test +fixtures +*.log +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..12d14ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# STCourses: the server and the SPA in one image. AGPL-3.0-only. +# Build: docker build -t stc-app . +# Run: see docker-compose.yml and docs/operations.md +FROM oven/bun:1.3 + +# Tools the importer and the studio call: ffmpeg/ffprobe (frames, durations), git (studio repos, code imports), +# unzip (Packt and code zips), python3 with PyMuPDF (PDF page images and text; STC_PYTHON points at it). +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg git unzip ca-certificates python3 python3-pymupdf \ + && rm -rf /var/lib/apt/lists/* \ + && python3 -c "import pymupdf" + +WORKDIR /app + +# Dependencies first (cached unless a manifest or the lockfile changes). +COPY package.json bun.lock ./ +COPY apps/server/package.json apps/server/ +COPY apps/web/package.json apps/web/ +COPY packages/contracts/package.json packages/contracts/ +COPY packages/core/package.json packages/core/ +COPY packages/course-format/package.json packages/course-format/ +COPY packages/db/package.json packages/db/ +COPY packages/ui/package.json packages/ui/ +COPY packages/feature-learner/package.json packages/feature-learner/ +COPY packages/feature-import/package.json packages/feature-import/ +COPY packages/feature-studio/package.json packages/feature-studio/ +COPY packages/feature-ai/package.json packages/feature-ai/ +RUN bun install --production --frozen-lockfile + +# Sources (Bun runs TypeScript directly; tsconfig.json carries the @stc/* path mapping). +COPY tsconfig.json ./ +COPY apps apps +COPY packages packages +COPY overlays overlays + +# /data holds the database (SQLite), imported courses, studio repos and caches. /courses is for course repos you mount. +RUN mkdir -p /data /courses && chown bun:bun /data +VOLUME /data + +ENV NODE_ENV=production \ + STC_PORT=3000 \ + STC_DATA_DIR=/data \ + STC_DATABASE_URL=sqlite:///data/stcourses.db \ + STC_COURSE_DIRS=/app/overlays:/courses \ + STC_IMPORT_ROOTS=/import \ + STC_PYTHON=python3 + +USER bun +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD bun -e "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" +CMD ["bun", "apps/server/src/main.ts"] diff --git a/README.md b/README.md index 8a91f53..b738535 100644 --- a/README.md +++ b/README.md @@ -11,4 +11,9 @@ bun run typecheck # tsc --noEmit bun run dev # start the server with hot reload ``` +## Running it + +`STC_SECRET=... docker compose up -d` (add `--profile postgres` and `STC_DATABASE_URL` for Postgres). Mounts, +configuration and the measured memory budget are in [docs/operations.md](docs/operations.md). + License: AGPL-3.0-only. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cc071ed --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +# STCourses with Docker Compose. Project name "stc", so containers are stc-app-1 and stc-postgres-1. +# +# SQLite (default): STC_SECRET=... docker compose up -d +# Postgres: STC_SECRET=... STC_DATABASE_URL=postgres://stc:stc@postgres:5432/stc \ +# docker compose --profile postgres up -d +# +# Course repos go in ./courses (or set STC_COURSES_HOST); files to import go in ./import-inbox (or STC_IMPORT_HOST). +# Both are mounted read-only: originals are never changed. See docs/operations.md. +name: stc + +services: + app: + build: . + image: stc-app + restart: unless-stopped + ports: + - "${STC_HOST_PORT:-3000}:3000" + environment: + STC_SECRET: ${STC_SECRET:?set STC_SECRET to a long random string} + STC_PUBLIC_URL: ${STC_PUBLIC_URL:-http://localhost:3000} + STC_DATABASE_URL: ${STC_DATABASE_URL:-sqlite:///data/stcourses.db} + STC_UPLOAD_MAX_MB: ${STC_UPLOAD_MAX_MB:-100} + STC_AI_ALLOWED_BASE_URLS: ${STC_AI_ALLOWED_BASE_URLS:-} + STC_TRANSCRIBE_URL: ${STC_TRANSCRIBE_URL:-} + depends_on: + postgres: + condition: service_healthy + required: false # only with --profile postgres + volumes: + - stc-data:/data + - ${STC_COURSES_HOST:-./courses}:/courses:ro + - ${STC_IMPORT_HOST:-./import-inbox}:/import:ro + + postgres: + image: postgres:17 + profiles: ["postgres"] + restart: unless-stopped + environment: + POSTGRES_USER: stc + POSTGRES_PASSWORD: ${STC_PG_PASSWORD:-stc} + POSTGRES_DB: stc + volumes: + - stc-pg:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U stc -d stc"] + interval: 5s + retries: 20 + +volumes: + stc-data: + stc-pg: diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..64c56da --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,85 @@ +# 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:` | 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://` 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; `/courses` and `/worktrees` are always added | +| `STC_IMPORT_ROOTS` | `/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__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 `` 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.