Self-hosted web UI that mirrors files from any Moodle instance to local disk, on a schedule. Configure per-course file-type filters, watch sync runs live, and optionally keep a Kavita book library in sync via hardlinks.
- Connects to one or more Moodle sites using the mobile Web Services API (username/password → long-lived token).
- Lists your enrolled courses and lets you toggle which ones to sync.
- Per-course filters: file-type categories (PDFs, Office docs, images, video, etc.) and filename regex.
- Downloads files into a tree that mirrors Moodle's sections/modules:
data/account-<id>/<course>/<section>/<module>/<filename>. - Re-syncs are incremental and survive rename of sections/modules (files get moved, not redownloaded).
- Scheduled runs via cron expression, or trigger one on demand.
- Live progress over WebSocket: stats, per-file events, error logs.
- Optional Kavita mirror: rebuilds a second tree under a configurable path using hardlinks (no extra disk usage). Auto-runs after each sync and/or after a course/account delete.
- Server: NestJS 11, better-sqlite3 + Drizzle ORM,
@nestjs/schedulefor cron,@nestjs/platform-wsfor the live stream. - Client: React 19 + Vite 6, Tailwind v4, React Router 7, Lucide icons, Sonner toasts.
- Infra: pnpm workspace + Turbo. Dev devcontainer with traefik + mkcert-signed local TLS.
A single image bundles the server and the built client. The server serves the SPA at /, the REST API at /api/*, and the WebSocket at /ws/*.
docker run --rm -p 3000:3000 \
-v $(pwd)/data:/data \
-v $(pwd)/kavita-library:/kavita-library \
-e SYNC_INTERVAL_CRON="0 * * * *" \
ghcr.io/ghosty2004/moodle-dl-web:latestOpen http://localhost:3000. The /data volume holds the SQLite database + downloaded files. /kavita-library is optional and only used when the Kavita mirror is enabled in Settings.
services:
moodle-dl-web:
image: ghcr.io/ghosty2004/moodle-dl-web:latest
container_name: moodle-dl-web
ports:
- "3000:3000"
volumes:
- ./data:/data
- ./kavita-library:/kavita-library
environment:
SYNC_INTERVAL_CRON: "0 * * * *"
restart: unless-stoppedSave as docker-compose.yaml and run docker compose up -d. Logs: docker compose logs -f.
Environment variables:
| Var | Required | Description |
|---|---|---|
SYNC_INTERVAL_CRON |
no | 5-field cron, e.g. 0 * * * *. Empty disables the scheduler |
CLIENT_URL |
no | Only needed if you serve the client from a different origin than the API (enables CORS for that origin) |
API_PREFIX |
no | HTTP prefix for API routes (set to api in the shipped image) |
SERVE_STATIC |
no | true to serve the client bundle from ./public |
The project is set up around a VS Code devcontainer.
cp .devcontainer/.env.example .devcontainer/.envThen "Reopen in Container". Inside:
pnpm run dev # runs server (3000) and client (5173) via turboTraefik exposes the apps over HTTPS on:
https://moodle-dl-web.localdev— client (Vite dev server)https://api.moodle-dl-web.localdev— server REST + WebSocket
Add both hostnames to your host's /etc/hosts pointing at 127.0.0.1.
Schema lives in apps/server/src/db/schema.ts. After any change:
cd apps/server
pnpm run db:generate # emits a new SQL file under drizzle/Migrations are applied automatically on server boot.
.github/workflows/publish.yml builds the single production image for linux/amd64 + linux/arm64 and pushes to GHCR on:
- push to
main→ tagmain,sha-<short>,latest - push of
v*.*.*tag → semver tags
MIT