A calm, self-hosted home for your notes.
Google Keepβstyle notes on your own server, where every note is a plain Markdown file.
βΆ Try the live demo (no signup) Β· Website Β· Docs Β· Discord
Note
Papyra 1.0 β the first stable release. The file format, data layout and upgrade
path are stable from here on. Still: back up your data volume, hidden .papyra folders included.
- Your notes are just files. Every note is a
.mdfile with a small YAML header, in a folder you own. If you ever stop using Papyra, you still have a normal folder of Markdown. - One container, one volume. No database server, no search server, no sidecars. amd64 and arm64, so a Raspberry Pi 4 or 5 is plenty.
- Plays well with your other tools. Obsidian, VS Code,
grep, Syncthing and git can all work on the same folder. Papyra notices outside changes and catches up live. - Private by default. No telemetry, no hosted service, nothing phoning home. GPLv3.
Edit the file from anywhere: the app catches up on its own.
You need Docker with Compose.
curl -O https://raw.githubusercontent.com/lyfie-org/papyra/main/docker-compose.hub.yml
docker compose -f docker-compose.hub.yml up -dOpen http://localhost:8080 and create the first account. That's it.
Running it on a server or NAS? A few things worth knowing
- HTTPS / reverse proxy: put Papyra behind Caddy, Traefik, Nginx Proxy Manager or a Cloudflare Tunnel. See HTTPS.
- Opening it by IP over plain http (e.g.
http://192.168.1.50:8080or a Tailscale IP)? SetPAPYRA_ALLOW_INSECURE_COOKIES: "true"in the compose file, or the browser won't keep you signed in. Only do this on a network you trust. - File ownership:
PUID/PGIDin the compose file decide who owns the files in the volume, so you can edit them over SMB or Syncthing. - Want the notes in a folder you can see? Swap the named volume for a bind mount, e.g.
./papyra-data:/data. See Your files. - Updating:
docker compose -f docker-compose.hub.yml pull && docker compose -f docker-compose.hub.yml up -d. - Every setting: Configuration.
Also in the box: pictures and PDFs inside notes Β· to-do lists Β· tags and smart collections Β· sharing with people or by link (with expiry) Β· passkeys, an authenticator app and single sign-on (OIDC) Β· import from Obsidian and Google Keep Β· export everything to a zip Β· sealed backups Β· backup to a private GitHub repo Β· installs as an app on your phone.
- Create your account. A short setup walks you through a username, an authenticator app, email, a password and a vault PIN. (Restoring a backup instead? That's an option on the first screen.)
- Bring your notes. Settings β Data & Storage imports an Obsidian vault (.zip) or a Google Keep Takeout. (Importing)
- Write. Click New note, or press / in a note for headings, lists, tables, images and embeds. (Writing)
- Lock what's private. Lock any note with the vault PIN from setup; add your fingerprint or face in Settings β Security.
- Put it on your phone. Open Papyra in your phone's browser and choose Add to Home Screen.
- Back it up. Settings β Data & Storage downloads a sealed backup; Settings β Backup can push to a private GitHub repo. (Backups)
Stuck? Check Troubleshooting or ask in #papyra-help on Discord.
- Discord: discord.gg/KQNsjGAUUz. Help, ideas, release news, and the people building it.
- Bugs and feature requests: GitHub Issues.
- Security issues: please report privately via GitHub security advisories.
- Want to help? See CONTRIBUTING.md. Bug reports, docs fixes and ideas all count.
Papyra is a Lyfie project, alongside luthor, the editor it's built on. It was vibe-coded: designed, tested and steered by a person, with AI coding assistants writing most of the code.
π§ How it works: files first, caches second
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.
π οΈ Tech stack
| 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.11 (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, eleven 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.
π Repository structure and the data volume
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 and embed syntax
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 document as its desktop-style icon and name; Preview (or a double-click) opens it at page 3 |
![[q3-411e00.pdf|Q3 report]] |
the same, shown as "Q3 report" (Rename writes the alias; the file keeps its name) |
 <!-- align:right --> |
a web image, 300 px wide, aligned right |
![[youtube:https://β¦|Talk|640x360]] <!-- align:left --> |
an embedded video with a caption, aligned left |
![[iframe:https://β¦|800x600]] <!-- align:right --> |
an embedded page β Embed a link (or /embed) takes any link: a Google/Apple Maps place, a song, a video, a document or a page |
![[card:https://β¦]] |
a link card (title, summary, picture) β what a site that refuses to be framed becomes |
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.
π§ͺ Build, run and test
# 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
# Accessibility: axe-core (WCAG 2.2 AA) over every view, menu and dialog, light and
# dark, desktop and phone, plus keyboard and caret/selection contrast β run in CI
pnpm --filter papyra-web run check:a11y| Suite | What it is | Count |
|---|---|---|
papyra.api/tests/Papyra.Tests |
xUnit β storage, path jail, search index, conflict detection, cold-boot diff, auth, sharing, backups | 869 test cases |
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. Free to use, change and share.


