| admin | ||
| migrations | ||
| public | ||
| scripts | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| playwright.config.ts | ||
| README.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
Cantilever
A Markdown-first self-hosted document repository — a digital reading room for long-form writing. Write in Markdown, organize with collections and series, publish on a schedule, and serve a beautiful public site with full-text search, RSS/Atom feeds, threaded comments, and citation generation.
Features
- Markdown-first: Write your documents in Markdown, and let Cantilever handle the rest. No need to worry about formatting (including KaTeX math and syntax-highlighted code!) or styling — just focus on your content.
- Document lifecycle: Private --> Unlisted --> Scheduled --> Public. Control who can see your documents and when they go live. Scheduled documents publish themselves.
- Full-text search: Search across all your documents with SQLite FTS5 (ranked, stemmed, with highlighted snippets), with a LIKE fallback for malformed queries.
- Git-based versioning: Every change to a document is committed to its own Git repository with a commit message, so you can browse and revert to earlier versions — and optionally show readers a public edit history with diffs.
- Collections and series: Group documents into collections, or into ordered series with previous/next navigation.
- Static site export: Export your entire public site as flat files, ready to be hosted on any platform that serves static files (e.g., GitHub Pages, Netlify, Vercel).
- Citation generation: Generate citations for your documents in BibTeX, APA, MLA, and Chicago with a single click.
- Webhooks: Integrate with other services (e.g., Zapier, IFTTT) through HMAC-signed webhooks fired when documents are published.
- Threaded comments: Allow readers to engage with your content through threaded, moderated comments (with optional tripcodes), and email notifications for moderation.
- RSS/Atom feeds: Keep your readers updated with RSS and Atom feeds, with full content or excerpts.
- Dark mode: A sleek dark mode for comfortable reading in low-light environments.
- Admin SPA: A single-page application for managing your document repository: writing with a live preview and image uploads, organizing collections and series, moderating comments, and configuring settings.
- Custom pages: Create standalone pages (About, Contact, etc.) in Markdown, served alongside your documents.
- Image uploads: Upload images (or a zip of them) and reference them by filename in your Markdown.
- API keys: Create read-only API keys for programmatic access to the public API — including documents that aren't public yet.
- JSON import/export: Bulk export and import your entire repository as a single JSON file — perfect for backups and migration.
- Sitemap: Auto-generated
sitemap.xmlfor search engine discovery. - Random discovery:
/randomredirects to a random public document. - Fully dockerized: Easy to deploy and manage with Docker, ensuring a consistent environment across different platforms.
Architecture
By default, Cantilever runs a single Fastify server: the public site, the admin panel (/admin/), and the admin API (/api/admin/*) share one port, and the admin API is protected by ADMIN_PASSWORD.
Optionally, the admin panel can be moved to a separate port by setting ADMIN_PORT:
| Mode | Public site | Admin panel and API | Admin authentication |
|---|---|---|---|
| Single port (default) | PUBLIC_PORT |
PUBLIC_PORT |
ADMIN_PASSWORD required (the admin API is disabled without it) |
Split (ADMIN_PORT) |
PUBLIC_PORT |
ADMIN_PORT |
ADMIN_PASSWORD optional (keep the admin port private) |
In split mode, nothing under /admin or /api/admin exists on the public port, so publishing the public port never exposes the admin interface.
Getting Started
With docker-compose:
services:
cantilever:
image: marvilco/cantilever:latest
container_name: cantilever
ports:
- "3234:3234"
volumes:
- ./data:/app/data
environment:
- PUBLIC_URL=https://yourdomain.com
- ADMIN_PASSWORD=your_admin_password
restart: unless-stopped
Then open http://localhost:3234/admin/ and sign in with your ADMIN_PASSWORD. The image has a built-in health check on /health, runs as an unprivileged user (uid 1000), and applies database migrations automatically on startup.
Everything Cantilever stores lives in /app/data — the SQLite database (db/), the per-document Git repositories and uploaded images (files/), and static exports (export/) — so that is the one volume to mount and back up.
To keep the admin panel off the public port, add ADMIN_PORT and publish it only on a private interface:
ports:
- "3234:3234"
- "127.0.0.1:3235:3235"
environment:
- ADMIN_PORT=3235
Configuration
All configuration is via environment variables:
| Variable | Default | Description |
|---|---|---|
PUBLIC_PORT |
3234 |
Public server port |
ADMIN_PORT |
(empty) | Separate admin port. Unset (or equal to PUBLIC_PORT) means single-port mode |
PUBLIC_URL |
http://localhost:<port> |
Public URL (e.g., https://yourdomain.com) for links, feeds, citations, and OG tags |
ADMIN_PASSWORD |
(empty) | Admin password, sent as a Bearer token. Required in single-port mode |
LOG_LEVEL |
info |
Logging level (trace, debug, info, warn, error, fatal, silent) |
SMTP_HOST |
(empty) | SMTP server hostname for email notifications |
SMTP_PORT |
587 |
SMTP server port (465 uses TLS) |
SMTP_USER |
(empty) | SMTP username |
SMTP_PASS |
(empty) | SMTP password |
SMTP_FROM |
(empty) | From address for notification emails (notifications are sent to it too) |
The SMTP_* variables only seed the email settings on first boot; after that, the admin panel's Settings section is authoritative (and email notifications are switched on there). Site title, subtitle, default author and license, feed content, and the public edit history are also configured in the admin panel.
Writing
Documents are plain Markdown (CommonMark plus GitHub-style tables and strikethrough):
- Math:
$inline$and$$display$$, rendered with KaTeX. Dollar signs in code and prose like "$5 and $10" are left alone; write\$for a literal dollar. - Code: fenced blocks with a language (
```ts) are syntax-highlighted. - Images: upload them in the editor, then reference them by filename —
is served from/images/diagram.png. - Headings become anchors and, with two or more, a table of contents beside the document.
Raw HTML in Markdown is escaped, not rendered. Every change to a document's body needs a short commit message, which becomes part of its version history (and is visible to readers if the public edit history is on).
API Overview
Responses are JSON with camelCase fields: { data, meta? } on success, { error } with a matching HTTP status code on failure.
Public API
| Method | Path | Description |
|---|---|---|
GET |
/ |
Homepage (SSR) |
GET |
/search?q=... |
Search results page (SSR) |
GET |
/collections, /collections/:id |
Collections listing and collection page (SSR) |
GET |
/series, /series/:id |
Series listing and series page (SSR) |
GET |
/random |
Redirect to a random public document |
GET |
/:slug |
Custom page or document (SSR) |
GET |
/feed.xml |
RSS feed |
GET |
/feed.atom |
Atom feed |
GET |
/sitemap.xml |
Sitemap |
GET |
/thumb/site.png, /thumb/:slug.png |
OG image thumbnails |
GET |
/images/:filename |
Uploaded images |
GET |
/health |
Health check |
GET |
/api/documents |
List public documents (?collectionId, ?seriesId, ?page, ?limit) |
GET |
/api/documents/:slug |
Single document |
GET |
/api/documents/:slug/versions |
Version history (if enabled) |
GET |
/api/documents/:slug/versions/:hash |
Version content (if enabled) |
GET |
/api/documents/:slug/versions/:hash/render |
Version rendered as HTML (if enabled) |
GET |
/api/documents/:slug/diff?from=&to= |
Diff between versions (if enabled) |
GET |
/api/search?q=... |
Search results |
GET |
/api/collections |
List collections |
GET |
/api/series |
List series |
GET |
/api/pages |
List public pages |
GET |
/api/pages/:slug |
Single page |
GET |
/api/comments/:documentId |
Approved comments, threaded |
POST |
/api/comments/:documentId |
Submit a comment for moderation |
GET |
/api/citations/:documentId |
Citations (BibTeX, APA, MLA, Chicago) |
API keys: send X-Cantilever-API-Key: <key> to read Private, Unlisted, and Scheduled documents and pages through the JSON API as well (soft-deleted content is never returned). Keys are read-only and never grant access to the admin API; an unknown key simply falls back to the normal public rules.
Admin API
Every admin request needs Authorization: Bearer <ADMIN_PASSWORD> (unless running in split mode without a password).
| Method | Path | Description |
|---|---|---|
GET |
/api/admin/session |
Check credentials |
GET/POST |
/api/admin/documents |
List / Create documents |
GET/PUT/DELETE |
/api/admin/documents/:id |
Get / Update / Delete (?hard=true to purge) |
GET |
/api/admin/documents/:id/versions |
Git version history |
GET |
/api/admin/documents/:id/versions/:hash |
Specific version content |
POST |
/api/admin/documents/:id/versions/:hash/revert |
Revert to version |
POST |
/api/admin/render |
Render Markdown (editor preview) |
GET/POST |
/api/admin/collections |
List / Create collections |
GET/PUT/DELETE |
/api/admin/collections/:id |
Get / Update / Delete collection |
POST |
/api/admin/collections/:id/documents |
Add document to collection |
DELETE |
/api/admin/collections/:id/documents/:docId |
Remove document from collection |
GET/POST |
/api/admin/series |
List / Create series |
GET/PUT/DELETE |
/api/admin/series/:id |
Get / Update / Delete series |
POST |
/api/admin/series/:id/documents |
Add document to series |
DELETE |
/api/admin/series/:id/documents/:docId |
Remove document from series |
PUT |
/api/admin/series/:id/reorder |
Reorder documents in series |
GET |
/api/admin/comments/pending |
Pending moderation queue |
POST |
/api/admin/comments/:id/approve |
Approve a comment |
DELETE |
/api/admin/comments/:id |
Reject / Delete a comment |
POST |
/api/admin/comments |
Create admin-authored comment |
GET/POST |
/api/admin/licenses |
List / Create licenses |
GET/PUT/DELETE |
/api/admin/licenses/:id |
Get / Update / Delete license |
GET/POST |
/api/admin/webhooks |
List / Create webhooks |
GET/PUT/DELETE |
/api/admin/webhooks/:id |
Get / Update / Delete webhook |
GET/PUT |
/api/admin/settings |
Get all / Update settings |
GET/DELETE |
/api/admin/settings/:key |
Get / Delete one setting |
GET/POST |
/api/admin/pages |
List / Create custom pages |
GET/PUT/DELETE |
/api/admin/pages/:id |
Get / Update / Delete page |
GET/POST |
/api/admin/api-keys |
List / Create API keys |
DELETE |
/api/admin/api-keys/:id |
Revoke an API key |
GET |
/api/admin/images |
List uploaded images |
POST |
/api/admin/images/upload |
Upload an image |
POST |
/api/admin/images/unzip |
Extract a zip of images |
DELETE |
/api/admin/images/:filename |
Delete an image |
POST |
/api/admin/export |
Run a static site export |
GET |
/api/admin/export/status |
Export status check |
GET |
/api/admin/export/json |
Download full JSON export |
POST |
/api/admin/import/json |
Import from JSON export |
Webhooks receive a JSON POST ({ event, timestamp, data }) with an X-Cantilever-Event header and an X-Cantilever-Signature header: the hex HMAC-SHA256 of the raw body, keyed with the webhook's secret. Failed deliveries are retried up to three times with exponential backoff.
Development
npm install
npm run dev # tsx watch on src/app.ts; data goes to ./data
npm test # unit + integration tests (Vitest)
npm run test:coverage # with coverage thresholds
npm run test:e2e # end-to-end admin flow (Playwright; run `npx playwright install chromium` once)
npm run lint
Run npm run dev with ADMIN_PASSWORD=... set (or ADMIN_PORT=... for an unauthenticated local admin port). There is no build step: TypeScript runs directly through tsx, in development and in production.
Schema changes go in a new numbered .sql file in migrations/ (the source of truth), mirrored afterwards in src/db/schema.ts.
Tech Stack
| Component | Technology |
|---|---|
| Runtime | Node.js 22 (TypeScript via tsx, no build step) |
| Language | TypeScript (strict) |
| Web Framework | Fastify 5 |
| Database | better-sqlite3 (WAL mode) |
| ORM | Drizzle ORM |
| Search | SQLite FTS5 |
| Git Integration | simple-git |
| Markdown | markdown-it, KaTeX (client-side) |
| Syntax Highlight | highlight.js (server-side) |
| nodemailer | |
| Logging | Pino + pino-pretty |
| Image Processing | Sharp + text-to-svg |
| Zip Extraction | fflate |
| Admin UI | Alpine.js |
| Testing | Vitest + Playwright |
| Linting | ESLint |
| Containerization | Docker |
Design Decisions
- Drizzle ORM: Typed queries with zero runtime overhead. Hand-written
.sqlmigrations are the schema's source of truth; Drizzle's schema is a typed mirror of them. FTS5 tables and triggers live only in the migrations. - No frontend framework: Public pages are server-rendered with template literal functions (the same functions produce the static export). The admin panel uses Alpine.js from a CDN. Zero build step for client-side code.
- Single port by default, split optional: One port keeps deployment simple, with
ADMIN_PASSWORDguarding the admin API — and Cantilever refuses to serve that API without a password. SettingADMIN_PORTmoves the admin interface to its own port for anyone who wants it off the public network entirely. - No external search engine: Cantilever implements full-text search using FTS5 in SQLite, with a LIKE fallback for malformed queries. This keeps the architecture simple and self-contained.
- Per-document Git repositories: Each document is stored in its own Git repository (via simple-git), allowing for granular version control and history tracking.
- Server-side highlighting: Code is highlighted with highlight.js while rendering, so live pages, the static export, and feeds are identical and need no client-side highlighter.
- Email through nodemailer: Comment moderation alerts are optional — no SMTP settings means no emails, and a failing mail server never affects commenters.
- WAL mode for SQLite: Cantilever uses Write-Ahead Logging (WAL) mode for SQLite to improve concurrency and performance.
License
Cantilever is licensed under the GNU Affero General Public License v3.0.