A Markdown-first, self-hosted document repository.
Find a file
2026-10-08 12:01:25 +08:00
admin feat(admin): add Alpine.js admin SPA 2026-10-07 18:49:56 +08:00
migrations fix(db): make document full-text search work and escape search snippets 2026-10-07 13:24:29 +08:00
public feat(public): add public stylesheet and client script 2026-10-07 18:16:13 +08:00
scripts feat(docker): add Dockerfile, docker-compose, and dev script 2026-10-07 18:58:12 +08:00
src test: add integration, port-isolation, and security tests 2026-10-08 11:11:05 +08:00
tests test(e2e): add Playwright admin flow 2026-10-08 11:37:47 +08:00
.dockerignore feat(docker): add Dockerfile, docker-compose, and dev script 2026-10-07 18:58:12 +08:00
.gitignore feat(docker): add Dockerfile, docker-compose, and dev script 2026-10-07 18:58:12 +08:00
docker-compose.yml feat(docker): add Dockerfile, docker-compose, and dev script 2026-10-07 18:58:12 +08:00
Dockerfile feat(docker): add Dockerfile, docker-compose, and dev script 2026-10-07 18:58:12 +08:00
eslint.config.mjs feat: initialize project with package.json, tsconfig.json, and vitest configuration 2026-06-22 21:18:56 -06:00
LICENSE Initial Commit 2026-06-22 19:20:03 -06:00
package-lock.json feat(docker): add Dockerfile, docker-compose, and dev script 2026-10-07 18:58:12 +08:00
package.json feat(docker): add Dockerfile, docker-compose, and dev script 2026-10-07 18:58:12 +08:00
playwright.config.ts test(e2e): add Playwright admin flow 2026-10-08 11:37:47 +08:00
README.md docs: update README for v2 2026-10-08 12:01:25 +08:00
tsconfig.json feat: initialize project with package.json, tsconfig.json, and vitest configuration 2026-06-22 21:18:56 -06:00
vitest.config.ts feat: initialize project with package.json, tsconfig.json, and vitest configuration 2026-06-22 21:18:56 -06:00

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.xml for search engine discovery.
  • Random discovery: /random redirects 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 — ![A diagram](diagram.png) 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)
Email 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 .sql migrations 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_PASSWORD guarding the admin API — and Cantilever refuses to serve that API without a password. Setting ADMIN_PORT moves 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.