Skip to content
lyfie-orgPublic

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

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Latest commit

Β 

History

389 Commits

Folders and files

Papyra logo

Papyra

A calm, self-hosted home for your notes.
Google Keep–style notes on your own server, where every note is a plain Markdown file.

Latest release Docker pulls License: GPL v3 Join us on Discord

β–Ά Try the live demo (no signup) Β· Website Β· Docs Β· Discord


The Papyra desk: pinned and coloured note cards in a warm, paper-like grid

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.

✨ Why Papyra

  • Your notes are just files. Every note is a .md file 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.

A shell appends a line to a note's file and the open app updates on its own
Edit the file from anywhere: the app catches up on its own.

πŸš€ Install in two commands

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 -d

Open 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:8080 or a Tailscale IP)? Set PAPYRA_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 / PGID in 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.

πŸ—’οΈ What you get

✍️ Write without a save button

Click a card and type. It saves when you pause, works offline, and syncs when you're back.

A note open in the focused editor

πŸ”Ž Find anything, fast

Press Ctrl + K. Full-text search across titles, text and tags, with highlighted matches.

Typing in search narrows the results and highlights matches

πŸ‘₯ Edit together, live

Share a note and type in it at the same time, with named cursors. It's still a .md file on disk.

Two people editing the same note with named cursors

πŸ”’ A vault for private notes

Locked notes are blurred everywhere, open with a PIN or your fingerprint, and are encrypted on disk.

Typing the vault PIN opens a locked note

πŸ•°οΈ Go back in time

Every note keeps its history. Step through versions, see what changed, restore one.

The history view comparing two versions of a note

πŸ’¬ Comment and link

Select text to comment. Type [[ to link notes; every note lists what links to it.

A note with highlighted passages and comment threads

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.

Papyra on a phone

πŸ‘£ Your first ten minutes

  1. 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.)
  2. Bring your notes. Settings β†’ Data & Storage imports an Obsidian vault (.zip) or a Google Keep Takeout. (Importing)
  3. Write. Click New note, or press / in a note for headings, lists, tables, images and embeds. (Writing)
  4. Lock what's private. Lock any note with the vault PIN from setup; add your fingerprint or face in Settings β†’ Security.
  5. Put it on your phone. Open Papyra in your phone's browser and choose Add to Home Screen.
  6. 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.

πŸ’¬ Community

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.


πŸ§‘β€πŸ’» For developers

🧭 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

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.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

Website (/papyra.app)

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

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

πŸ“„ License

GNU GPL v3.0. Free to use, change and share.

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

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages