A calm, self-hosted home for your notes.
Your notes stay as ordinary Markdown files on your own server. Full-text search, wiki links and backlinks, offline editing, sharing, and passkey-locked vaults β in one container.
Website Β· Live demo Β· Docs Β· Docker Hub
Warning
ACTIVE DEVELOPMENT β v0.1.2. Anything can change and break anytime. If you adopt it today, keep the backups it makes for you.
curl -O https://raw.githubusercontent.com/lyfie-org/papyra/main/docker-compose.hub.yml
docker compose -f docker-compose.hub.yml up -dThen open http://localhost:8080 and create the first account. One container,
one volume β there is nothing else to stand up alongside it.
See the install docs for HTTPS, reverse
proxies, and file ownership (PUID/PGID).
The filesystem is the source of truth. Every note is a .md file with YAML
frontmatter, sitting in a folder you own. SQLite and the Lucene index are
disposable caches β delete either one and Papyra rebuilds it from the files.
Nothing about your notes depends on Papyra continuing to exist.
That means Syncthing, Dropbox, git or Obsidian can edit the vault behind the app's back. Papyra watches the folder, catches up, and surfaces sync-conflict files instead of choking on them.
| Runtime | .NET 10 Minimal APIs (C# 14), single Program.cs route registration |
| Markdown & frontmatter | Markdig 0.42 (UseYamlFrontMatter) + YamlDotNet 16.3 β unknown frontmatter keys are round-tripped untouched |
| Full-text search | Lucene.Net 4.8.0-beta00017 (+ Analysis.Common, QueryParser, Highlighter) |
| Cache / metadata | EF Core 10 + SQLite β rebuildable from the .md files, never the authority |
| Real-time | FileSystemWatcher (one per tenant) + SignalR for metadata-only push |
| Auth | Cookie sessions, BCrypt passwords, Fido2.AspNet passkeys, optional OIDC SSO |
| Git sync | LibGit2Sharp β per-user credentials, never force-pushes |
| Docs | OpenAPI + Scalar.AspNetCore |
Also shipped but held back behind a feature flag: local AI (Whisper
transcription, Tesseract OCR, Ollama embeddings and RAG chat). The routes return
404 and are excluded from OpenAPI until PAPYRA_AI_ENABLED is turned on.
| Framework | React 19 / TypeScript 6 / Vite 8 |
| Routing | react-router-dom 7 |
| Data fetching | @tanstack/react-query 5 β no Redux, no Zustand |
| Editor | @lyfie/luthor 2.9.5 (our Lexical 0.40 wrapper). Standard Lexical or alternative rich-text engines are not used here. |
| Real-time | @microsoft/signalr |
| Offline | IndexedDB outbox + service worker; autosave, no save button |
Astro 7 static site β marketing pages, ten docs pages, the rendered API
reference, and a live in-browser demo built from papyra.web with a fake
backend. Deployed to Cloudflare Pages from GitHub Actions.
papyra/
βββ papyra.api/ # .NET 10 Minimal API + xUnit and edge tests
β βββ src/Papyra.Api/ # Program.cs (all routes), Storage/, Security/, Hubs/, Features/
β βββ tests/
β βββ Papyra.Tests/ # 47 xUnit classes
β βββ edge/ # Black-box HTTP harness against a running instance
βββ papyra.web/ # Vite + React 19 app (also built in demo mode)
βββ papyra.app/ # Astro marketing site + docs + demo host
βββ data/ # Git-ignored local dev storage volume (see below)
βββ Dockerfile # 4-stage, multi-arch (amd64 + arm64)
βββ docker-compose.hub.yml # The self-hosting file β pulls lyfie/papyra:latest
βββ pnpm-workspace.yaml # papyra.web + papyra.app
data/
βββ users/{userId}/
β βββ notes/ # β the source of truth: your .md files
β βββ media/ # attachments referenced as ![[filename]]
β βββ .trash/ # soft-deleted items (and media/{day}/ for unused attachments)
β βββ .papyra/ # UI state: snapshots/, order.json, categories.json, avatar
β βββ media/ # per-attachment metadata, video posters, OCR/transcript text, thumbs/
β βββ archived-urls.txt # pages the web archiver has saved (each URL once)
βββ .papyra/
βββ papyra.db # SQLite cache β rebuildable
βββ lucene-index/ # full-text index β rebuildable
βββ media-jobs.json # OCR / transcription work still to do (or given up on)
βββ keys/ # Data Protection key ring β NOT disposable
Papyra-owned state is always in a hidden .papyra/ directory, never inside the
notes folder, so a sync client or file watcher never sees it churn.
Attachments live flat in media/ and are written into notes the way Obsidian
writes them, so a vault opens unchanged in either app:
| You write | You get |
|---|---|
![[photo.png]] |
the picture at its natural size (up to the column) |
![[photo.png|480]], ![[photo.png|480x320]] |
480 px wide (and that tall) β what dragging a handle saves |
![[photo.png|A sunset|480]] |
with alt text |
![[photo.png]] <!-- align:center --> <!-- caption:Our first night --> |
centred, captioned (the editor's toolbar writes these) |
![[report.pdf#page=3]] |
a file card; Preview PDF opens it in place at page 3 |
 |
a web image, 300 px wide |
![[youtube:https://β¦|Talk|640x360]] |
an embedded video with a caption |
In a table, escape the pipe: ![[photo.png\|200]]. A note is never rewritten
just because it was opened β only what you change is saved.
Uploads are checked by their bytes, not their names (a web page renamed .png
is stored as inert text), limited per kind (pictures 30 MB, GIFs 50 MB, audio and
documents 100 MB, video 500 MB) and named so two image.pngs never collide.
Pictures get small WebP thumbnails (cards and narrow columns never download
camera originals); videos get a poster frame captured by the browser. Optional
extras, all off unless configured:
| Setting | Turns on |
|---|---|
Ocr:TessDataPath |
the words in pictures become searchable (Tesseract eng.traineddata) |
Whisper:ModelPath |
WAV recordings are transcribed into their note (a Whisper model) |
Media:HeifConvert |
thumbnails for iPhone HEIC photos via libheif's heif-convert (most browsers already send JPEG) |
Text read out of attachments is kept beside them (.papyra/media/*.ocr.txt,
*.transcript.txt), so rebuilding search never re-runs OCR, and a locked note's
pictures are never searchable. Unused attachments move to the trash after a week
and are purged with it; anything an old version of a note shows is kept.
# Everything at once (API :5220 + web :5173)
pnpm install && pnpm dev
# Backend
dotnet build Papyra.slnx
dotnet test papyra.api/tests/Papyra.Tests/Papyra.Tests.csproj
# Frontend (from papyra.web/)
pnpm run build # type-check + production bundle
# Docker, from source
docker compose up --build # β :8080
# Editor + media end to end in real Chromium (own throwaway API; after `pnpm run build`
# it also checks the performance budgets: 200-picture note, 300-card desk)
pnpm --filter papyra-web run check:editor| Suite | What it is | Count |
|---|---|---|
papyra.api/tests/Papyra.Tests |
xUnit β storage, path jail, search index, conflict detection, cold-boot diff, auth, sharing, backups | 373 test cases across 47 classes |
papyra.api/tests/edge |
Black-box HTTP checks against a running instance β routing, auth policies, cookies, tenant isolation, real status codes | 239 checks (edge.sh 99 + edge2.sh 140) |
The edge harness needs a live instance and a throwaway vault; see
papyra.api/tests/edge/README.md.
Its assistant checks switch on the feature flag β with the assistant off they
assert its routes are correctly hidden β so both suites are green either way.
GNU GPL v3.0. Contributions welcome β open an issue first for anything large.