A Markdown-first, self-hosted document repository.
| migrations | ||
| src | ||
| .gitignore | ||
| eslint.config.mjs | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| 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!) or styling — just focus on your content.
- Document lifecycle: Private --> Unlisted --> Scheduled --> Published. Control who can see your documents and when they go live.
- Full-text search: Search accross all your documents with SQLite FTS5, with a LIKE fallback for malformed queries.
- Git-based versioning: Every change to your documents is tracked with Git, allowing you to revert to previous versions and maintain a history of your work (and optionally, share changes publicly).
- Static site export: Export your entire document repository as a static site, 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 various formats (e.g., APA, MLA, Chicago, BibTeX) with a single click.
- Webhooks: Integrate with other services (e.g., Zapier, IFTTT) to automate workflows based on document events.
- Threaded comments: Allow readers to engage with your content through threaded comments, with optional email notifications for moderation.
- RSS/Atom feeds: Keep your readers updated with RSS/Atom feeds for your collections and series.
- Dark mode: A sleek dark mode for comfortable reading in low-light environments.
- Admin SPA: A single-page application for managing your document repository, including creating and editing documents, organizing collections and series, and configuring settings.
- Custom pages: Create standalone pages (About, Contact, etc.) in Markdown, served alongside your documents.
- API keys: Create read-only API keys for programmatic access to the public API.
- 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
Cantilever runs two Fastify servers on separate ports by default (configurable):
| Server | Default Port | Purpose |
|---|---|---|
| Public | 3234 |
SSR pages, public API, RSS/Atom feeds |
| Admin | 3235 |
Admin SPA, content CRUD, settings |
Both servers can run on the same port when ADMIN_PORT is set to the same value as PUBLIC_PORT. In same-port mode, the admin interface is protected by ADMIN_PASSWORD.
Getting Started
With docker-compose:
services:
cantilever:
image: marvilco/cantilever:latest
container_name: cantilever
ports:
- "3234:3234" # Public server
- "3235:3235" # Admin server
volumes:
- ./cantilever/db:/app/data/db
- ./cantilever/files:/app/data/files
environment:
- PUBLIC_URL=https://yourdomain.com
- ADMIN_PASSWORD=your_admin_password
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3234/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
Configuration
All configuration is via environment variables:
| Variable | Default | Description |
|---|---|---|
PUBLIC_PORT |
3234 |
Public server port |
ADMIN_PORT |
3235 |
Admin server port |
PUBLIC_URL |
(empty) | Public URL (e.g., https://yourdomain.com) for generating links and feeds |
LOG_LEVEL |
info |
Logging level (trace, debug, info, warn, error, fatal) |
ADMIN_PASSWORD |
(empty) | Admin password (required if ADMIN_PORT is the same as PUBLIC_PORT) |
SMTP_HOST |
(empty) | SMTP server hostname for email notifications |
SMTP_PORT |
587 |
SMTP server port |
SMTP_USER |
(empty) | SMTP username |
SMTP_PASS |
(empty) | SMTP password |
SMTP_FROM |
(empty) | From address for notification emails |
API Overview
Public API (port 3234)
| Method | Path | Description |
|---|---|---|
GET |
/ |
Homepage (SSR) |
GET |
/search?q=... |
Search results page (SSR) |
GET |
/collections |
Collections listing (SSR) |
GET |
/series |
Series listing (SSR) |
GET |
/random |
Redirect to a random document |
GET |
/:slug |
Document or custom page (SSR) |
GET |
/feed.xml |
RSS feed |
GET |
/feed.atom |
Atom feed |
GET |
/sitemap.xml |
Sitemap |
GET |
/thumb/:slug.png |
OG image thumbnail |
GET |
/health |
Health check |
GET |
/api/documents |
List public documents (JSON) |
GET |
/api/documents/:slug |
Single document (JSON) |
GET |
/api/documents/:slug/versions |
Version history (if enabled) |
GET |
/api/documents/:slug/diff |
Diff between versions |
GET |
/api/search?q=... |
Search results (JSON) |
GET |
/api/collections |
List collections (JSON) |
GET |
/api/series |
List series (JSON) |
GET |
/api/pages |
List public pages (JSON) |
GET |
/api/pages/:slug |
Single page (JSON) |
GET |
/api/comments/:documentId |
Get comments for a document |
POST |
/api/comments/:documentId |
Submit a comment |
GET |
/api/citations/:documentId |
Generate citations |
Admin API (port 3235)
| Method | Path | Description |
|---|---|---|
GET/POST |
/api/admin/documents |
List / Create documents |
GET/PUT/DELETE |
/api/admin/documents/:id |
Get / Update / Delete document |
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 |
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/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 |
POST |
/api/admin/images/upload |
Upload an image |
POST |
/api/admin/images/unzip |
Extract a zip of images |
POST |
/api/admin/export |
Trigger 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 |
Tech Stack
| Component | Technology |
|---|---|
| Runtime | Node.js 22 |
| Language | TypeScript 5.7 Strict |
| Web Framework | Fastify 5 |
| Database | better-sqlite3 11 WAL |
| ORM | Drizzle ORM |
| Search | SQLite FTS5 |
| Git Integration | simple-git |
| Syntax Highlight | highlight.js |
| nodemailer | |
| Logging | Pino + pino-pretty |
| Image Processing | Sharp + text-to-svg |
| Testing | Vitest + Playwright |
| Linting | ESLint 9 |
| Containerization | Docker |
Design Decisions
- Drizzle ORM: Typed queries with zero runtime overhead. Schema is the single source of truth, migrations are plain
.sqlfiles. - No frontend framework: SSR uses template literal functions. Admin is vanilla JS. Zero build step for client-side code.
- Optional port separation: By default, Cantilever runs the public and admin servers on separate ports for added security (opening the public port does not expose the admin interface). Both servers can run on the same port — in this mode,
ADMIN_PASSWORDprotects the admin interface. - 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.
- 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.