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:
co-authored by
Claude Opus 5.5
parent
d71a95dbc1
commit
69aaedcb43
@@ -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
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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