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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d71a95dbc1
commit
69aaedcb43
5 files changed
+206
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.worktrees
|
||||||
|
.superpowers
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
data
|
||||||
|
reports
|
||||||
|
research_notes
|
||||||
|
docs
|
||||||
|
**/test
|
||||||
|
fixtures
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
+52
@@ -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"]
|
||||||
@@ -11,4 +11,9 @@ bun run typecheck # tsc --noEmit
|
|||||||
bun run dev # start the server with hot reload
|
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.
|
License: AGPL-3.0-only.
|
||||||
@@ -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:
|
||||||
@@ -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.
|
||||||
Reference in new issue
Block a user