Skip to content

Repository files navigation

Papyra Logo

Papyra

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.


πŸ“¦ Quick start

curl -O https://raw.githubusercontent.com/lyfie-org/papyra/main/docker-compose.hub.yml
docker compose -f docker-compose.hub.yml up -d

Then 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 idea

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.


πŸ› οΈ The tech stack

Backend (/papyra.api)

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.

Frontend (/papyra.web)

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

Website (/papyra.app)

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.


πŸ“‚ Repository structure

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

What lives in the data volume

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

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
![alt|300](https://…) 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.


πŸ§ͺ Development

# 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

Tests

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.


πŸ“„ License

GNU GPL v3.0. Contributions welcome β€” open an issue first for anything large.

About

Star if Papyra keeps your notes yours! A self-hosted, Google Keep-style notes app where every note is a plain Markdown file on your disk. Live collaboration, passkeys, encrypted locked notes, full-text search and one-container Docker setup. Crafted with πŸ’— for people who want their notes without lock-in.

Topics

Resources

Code of conduct

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages