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]>
This commit is contained in:
Lucas Winther
2026-10-30 15:17:58 +01:00
co-authored by Claude Opus 5.5
parent d71a95dbc1
commit 69aaedcb43
5 changed files with 206 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.worktrees
.superpowers
node_modules
**/node_modules
data
reports
research_notes
docs
**/test
fixtures
*.log
.env
+52
View File
@@ -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"]
+5
View File
@@ -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.
+51
View File
@@ -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:
+85
View File
@@ -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:<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.