# Papyra > Papyra is a free, open-source, self-hosted note-taking app — a private Google Keep alternative. Every note is a plain Markdown file on your own server. No telemetry, ever. Papyra is for anyone who wants a calm, beautiful notes app they own outright: home-labbers, privacy-minded people, families and small teams. It feels like Google Keep — a desk of colourful note cards — and goes as deep as a wiki: links, backlinks, search, history and live collaboration. Every note stays an ordinary Markdown file on the user’s own server, so nothing is ever locked in. - Website: https://papyra.app - Live demo (runs entirely in the browser, no sign-up): https://papyra.app/demo/ - Source code: https://github.com/lyfie-org/papyra (GNU GPL v3.0) - Docker image: https://hub.docker.com/r/lyfie/papyra (6,200+ pulls) - Made by Lyfie (https://www.lyfie.org), the open-source organisation founded by Rahul N. Anand; its editor is luthor (https://www.luthor.fyi) - Price: free. No paid tier, no licence key, no account with Lyfie. - Privacy: no telemetry, analytics, crash reports or usage pings; no third-party requests from the app. ## Features - Every note is a plain Markdown (.md) file with YAML frontmatter, on your own server - No telemetry, analytics, crash reports or usage pings — nothing is sent anywhere - Autosave with full version history; scrub back to any earlier version - Full-text search across notes, to-dos and collections, with highlighted matches - Wiki links ([[note]]) and backlinks - Live collaboration on shared notes, with cursors and presence - Sharing, @mentions, comments and notifications - Locked notes, encrypted at rest, opened with a vault PIN or biometrics - Passkey sign-in, authenticator-app two-factor, and single sign-on (OIDC) - Email alerts through your own SMTP server - Works offline as an installable web app; edits sync when you reconnect - Pictures, PDFs and video in notes, previewed inline - Import from Google Keep (Takeout) and Obsidian; drag in any .md file - Backups: Git sync (optionally encrypted), zip export, encrypted archives - Smart collections, tags, to-do lists, focus mode, light and dark themes - Multiple private accounts on one server - REST API with API keys, and signed webhooks - One Docker container, one volume; amd64 and arm64 ## Frequently asked questions ### What is Papyra? A free, open-source, self-hosted note-taking app. It is as simple as Google Keep, links notes like a wiki, and keeps every note as a plain Markdown file on your own server. ### Is Papyra really private? Yes. Papyra collects no telemetry, analytics or crash reports, and makes no third-party requests of its own — even its fonts come from your server. Your notes only leave it if you share one, set up a Git backup, or configure email alerts. Locked notes are encrypted on disk. ### Is Papyra free? Yes. It is open source under the GNU GPL v3.0, with no paid tier, no licence key and no account with us. ### Can I move my notes from Google Keep or Obsidian? Yes. Import a Google Keep Takeout archive — checklists, labels, colours and pins come with it — or upload an Obsidian vault with its links and attachments. Any Markdown file can also be dragged onto the desk. ### What do I need to run it? Docker. Papyra is one container with one volume, and runs on amd64 and arm64 — a NAS, a Raspberry Pi or a small VPS. Install takes one command. ### Does it work offline and on my phone? Yes. Papyra installs as an app on phones, tablets and computers, opens without a connection, and syncs offline edits when you are back. Native desktop and mobile apps are planned. ### What happens to my notes if I stop using Papyra? Nothing. They are ordinary Markdown files in a folder you own. Keep the folder and they stay readable in any text editor. ### Who makes Papyra? Papyra is a Lyfie project, built in the open on GitHub. Its editor is luthor, also from Lyfie. ## Install ```bash 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 — the first account created is the admin ``` --- ## Install Papyra > One compose file, one command. Papyra is a single container — no sidecars, no reverse proxy, no database to run. Source: https://papyra.app/docs/install/ Papyra ships as a Docker image, `lyfie/papyra`. The published recipe brings up a single container — there is nothing else to run alongside it. ```bash 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`. The first account you create is the administrator — there is no default password to change, because there is no default account. ## What you just started **`papyra`** — the whole application. One .NET process serves both the API and the web app, so there is no separate frontend container and no reverse proxy required to glue them together. It listens on `8080` inside the container. ## The data volume Everything lives under `/data`. Back that up and you have backed up everything. ``` /data ├── .papyra/ instance state — all of it disposable except keys/ │ ├── papyra.db SQLite cache, rebuildable from the notes │ ├── lucene-index/ full-text index, rebuildable │ └── keys/ data-protection keys — NOT disposable, see below └── users/ └── / ├── notes/ your notes, as .md files. This is the real thing. ├── media/ images you pasted or uploaded ├── .trash/ deleted notes, until retention expires └── .papyra/ ├── snapshots/ earlier revisions of each note ├── order.json manual card positions └── categories.json ``` The compose file uses a named volume. If you would rather see the files, swap it for a path: ```yaml volumes: - ./papyra-data:/data ``` Then set `PUID`/`PGID` to your own user so the files belong to you rather than root. Find them with `id -u` and `id -g`. > **`.papyra/keys` is the one directory you must not lose.** It holds the keys > your session cookies are signed with. Delete it and every signed-in browser is > silently logged out. It lives on the volume precisely so a container upgrade > does not invalidate every session. ## Changing the port The first number is the host side: ```yaml ports: - "9000:8080" ``` ## Upgrading ```bash docker compose -f docker-compose.hub.yml pull docker compose -f docker-compose.hub.yml up -d ``` On start-up Papyra reconciles the folder against its cache **before** it opens the port, so files that changed while it was down are folded in first. A green health check therefore means more than "the process is up" — it means disk and cache already agree. Roll back by pinning the previous tag instead of `latest`. ## Signing in over plain HTTP If you reach Papyra at a bare address like `http://192.168.1.50:8080`, signing in will appear to work and then drop you straight back to the login screen. Browsers refuse to keep a session cookie for a site that is not HTTPS — `localhost` excepted. On a network that is already private (a LAN, Tailscale, WireGuard): ```yaml PAPYRA_ALLOW_INSECURE_COOKIES: "true" ``` If Papyra is reachable from the open internet, set up [HTTPS](/docs/https/) instead and leave this off. --- ## Configuration > Every environment variable Papyra reads, what it is for, and when you actually need it. Most people need none of them. Source: https://papyra.app/docs/configuration/ Papyra is configured by environment variables. Every one is optional — the defaults are what most installations want. ## The full list | Variable | Default | What it does | | --- | --- | --- | | `PAPYRA_DATA_DIR` | `/data` | Where notes, cache and keys live. | | `PAPYRA_ALLOW_INSECURE_COOKIES` | `false` | Allow the session cookie over plain HTTP. | | `PAPYRA_ALLOWED_ORIGINS` | — | Extra CORS origins, comma-separated. | | `PAPYRA_TRUSTED_PROXIES` | — | Proxy IPs whose forwarded headers to believe. | | `PAPYRA_WEBAUTHN_DOMAIN` | `localhost` | Bare hostname passkeys are bound to. | | `PAPYRA_WEBAUTHN_ORIGINS` | — | Full origins passkeys may be used from. | | `PAPYRA_WHISPER_MODEL_PATH` | — | Enables audio transcription. | | `PAPYRA_OCR_TESSDATA_PATH` | — | Enables OCR of images. | | `PUID` / `PGID` | `1000` | Who owns the files on disk. | | `TZ` | `UTC` | Default time zone, as an IANA name (`Europe/London`, `Asia/Kolkata`). Each person can pick their own under Settings → Profile. | Lists are comma-separated, as in every other container image. > Papyra also accepts .NET's own spelling — `Papyra__AllowInsecureCookies`, > `Cors__AllowedOrigins__0` — and anything set that way wins. The plain-English > names exist because the .NET convention fails silently: get the casing or the > doubled underscore subtly wrong and the setting is ignored with no error at > all, which is the worst possible failure for something you touch once. ## The ones you are most likely to need ### `PAPYRA_ALLOW_INSECURE_COOKIES` Set `true` only when Papyra is reached over plain HTTP on a network that is already private. It makes the session cookie non-`Secure`, which is exactly the thing HTTPS is protecting. See [HTTPS and reverse proxies](/docs/https/). ### `PAPYRA_TRUSTED_PROXIES` Behind a reverse proxy, every request arrives from the proxy's address. Without this, your rate limits and logs see one client — the proxy — and a single visitor can exhaust everybody's budget. List the proxy's IP and Papyra will believe its forwarded headers: ```yaml PAPYRA_TRUSTED_PROXIES: "172.18.0.1" ``` Only list proxies you control. An unlisted proxy cannot spoof a client address past the rate limiter, which is the point. ### `PAPYRA_ALLOWED_ORIGINS` Only needed if you serve the web app from a *different* address than the API. The standard deployment serves both from one process at one origin, so normally you leave this out entirely. ### `PAPYRA_WEBAUTHN_DOMAIN` and `PAPYRA_WEBAUTHN_ORIGINS` Passkeys are bound to a domain by the browser, and it must be the bare hostname — no scheme, no port, no path: ```yaml PAPYRA_WEBAUTHN_DOMAIN: "notes.example.com" PAPYRA_WEBAUTHN_ORIGINS: "https://notes.example.com" ``` Passkeys require real HTTPS. They will not work over plain HTTP outside `localhost`, and no configuration flag changes that — it is the browser's rule. ## Configured in the app, not the environment Deliberately **not** environment variables, because changing them should not mean editing a compose file and restarting a container: - **SSO / OIDC** — Settings → SSO, applied without a restart - **SMTP** — Settings → Email - **Trash retention** — Settings → Data & Storage - **Git backup** — Settings → Backup, per account ## Optional extras, off unless configured `PAPYRA_WHISPER_MODEL_PATH` enables local audio transcription, and `PAPYRA_OCR_TESSDATA_PATH` enables OCR of images into searchable text. Both are no-ops until you point them at model data, and neither sends anything anywhere. --- ## HTTPS and reverse proxies > Why sessions and passkeys need real HTTPS, a working Caddy and nginx config, and what to tell Papyra about your proxy. Source: https://papyra.app/docs/https/ Papyra speaks plain HTTP on port 8080 and does not terminate TLS itself. Put a reverse proxy in front of it. ## Why it matters more than usual Two things break without HTTPS, and both fail in confusing ways: **Sessions.** Browsers refuse to store a `Secure` cookie for a site that is not HTTPS. Sign-in appears to succeed and then bounces you straight back to the login screen, with nothing in the logs to explain it. **Passkeys.** WebAuthn requires a secure context. Vault notes cannot be unlocked over plain HTTP outside `localhost`, and no Papyra setting overrides that — it is the browser's rule, not ours. ## Caddy Caddy gets a certificate for you, with no further configuration: ```caddyfile notes.example.com { reverse_proxy papyra:8080 } ``` ## nginx ```nginx server { listen 443 ssl http2; server_name notes.example.com; ssl_certificate /etc/letsencrypt/live/notes.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/notes.example.com/privkey.pem; location / { proxy_pass http://papyra:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Realtime updates are a websocket. Without these, notes stop updating # live across devices and the sidebar sits on "Server Offline". proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; } } ``` The websocket lines are the ones people forget. Papyra keeps working without them — it falls back and retries — but live updates stop and the connection indicator stays stubbornly offline. ## Tell Papyra about the proxy Behind a proxy, every request appears to come from the proxy's own address. Papyra will not believe forwarded headers from an unlisted source, so until you name your proxy, rate limits and logs see a single client and one visitor can exhaust everybody's budget. ```yaml PAPYRA_TRUSTED_PROXIES: "172.18.0.1" ``` Use the address Papyra actually sees. In compose that is the gateway of the shared network, not your router's LAN address: ```bash docker inspect papyra -f '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}' ``` ## Passkeys behind a domain ```yaml PAPYRA_WEBAUTHN_DOMAIN: "notes.example.com" PAPYRA_WEBAUTHN_ORIGINS: "https://notes.example.com" ``` `PAPYRA_WEBAUTHN_DOMAIN` is the bare hostname — no `https://`, no port, no trailing slash. `PAPYRA_WEBAUTHN_ORIGINS` is the full origin. Passkeys registered against one domain do not work on another. If you move Papyra to a new hostname, existing passkeys stop being offered and have to be re-registered. ## No HTTPS at all? On a genuinely private network — a home LAN, Tailscale, WireGuard — you can run without TLS: ```yaml PAPYRA_ALLOW_INSECURE_COOKIES: "true" ``` Sessions then work over plain HTTP. Passkeys still will not, because that is the browser's call. Do not do this on anything reachable from the open internet. The session cookie travels in clear text, and anyone on the path can take it. --- ## Working with the files directly > Papyra is not the only thing allowed to touch your notes. How to use Obsidian, Syncthing, git or a text editor alongside it without anything getting confused. Source: https://papyra.app/docs/files/ Your notes are `.md` files in `/data/users//notes/`. Papyra watches that folder and expects other things to write to it. ## What a note looks like ```markdown --- id: quarterly-review title: Quarterly review created: 2026-08-12T09:14:22Z categories: [work, planning] pinned: true --- # Quarterly review Pull the numbers from [[revenue-model]] before Thursday. ``` `id` is the filename without the extension. `categories` are what the UI calls categories and what everything else calls tags. `updated` is deliberately **not** stored — it is read from the file's modification time, so sync tools never see churn from a field only Papyra cares about. ## Editing from somewhere else Change a file with any editor and Papyra notices within moments: it re-reads the file, updates its cache and index, and pushes the change to any open browser tab. No polling, no refresh. That includes creating a note by dropping a `.md` file into the folder, and deleting one by removing the file. ## Using Obsidian on the same folder Point an Obsidian vault at `/data/users//notes/` and both work at once. Frontmatter keys Papyra does not own — Obsidian plugin fields, `aliases`, your own scripts' metadata — are carried through every read and write untouched. Papyra edits the keys it owns and puts everything else back exactly as it found it. A round trip through Papyra is not allowed to lose anything. `[[wiki links]]` mean the same thing in both. ## Syncthing, Dropbox, and other syncers These work, with one caveat worth understanding. If the same note is edited in two places while they are out of contact, the syncer will bring back two versions. Papyra detects that collision, keeps both, and offers a split-pane resolver rather than silently picking a winner. You will see a banner on the grid. To reduce how often that happens, sync the notes folder but exclude `.papyra/` — the snapshots and index are per-instance state, and syncing them between machines creates conflicts that mean nothing. ## Directories Papyra owns Leave these to Papyra: - `.papyra/` — snapshots, card order, category colours - `.trash/` — deleted notes awaiting retention expiry - `/data/.papyra/` — the SQLite cache, the Lucene index, and the data-protection keys The cache and index are disposable; Papyra rebuilds them from the notes on demand (Settings → Data & Storage). The **keys** directory is not disposable — losing it signs out every browser. ## git Papyra can commit and push your vault for you on a schedule — see [Backups](/docs/backups/). You can equally run `git` in the folder yourself; they do not conflict, though picking one is simpler. ## Filenames The `id` field and the filename are the same string. Renaming a file renames the note, and any `[[wiki link]]` pointing at the old name stops resolving — the same as it would in Obsidian. Note ids are unique **per account**, not globally. Two people on the same server can both have a note called `Inbox`, and they are different notes in different folders. --- ## Backups and git > Four ways to get your notes out — copying the folder, zip export, encrypted archives, and an automatic git mirror. Source: https://papyra.app/docs/backups/ Your notes are already plain files, so backup is a question of convenience rather than possibility. Papyra offers several routes; pick whichever you will actually keep doing. ## Copy the folder The whole of `/data` is the backup. `rsync` it, snapshot the volume, point your existing backup tool at it — nothing here needs Papyra's cooperation. ```bash rsync -a --delete /var/lib/docker/volumes/papyra-data/_data/ /backup/papyra/ ``` Restoring is putting the folder back. The SQLite cache and Lucene index inside `.papyra/` are rebuildable, so a backup that skips them is still complete — but **do keep `/data/.papyra/keys/`**, or every signed-in browser is logged out on restore. ## Zip export **Settings → Backup → Export**. Downloads your whole vault as a zip. Per account, on demand, no scheduling. Good for taking a copy before you change something. ## Encrypted archives **Settings → Backup → Generate**. Produces an AES-GCM encrypted archive with a key derived from a password you choose. **Settings → Backup → Restore** takes one back. Use this when the backup will sit somewhere you do not fully trust — object storage, a USB stick, a friend's NAS. > The password is not recoverable. Papyra does not store it and cannot reset it. > An encrypted backup with a forgotten password is lost. ## Automatic git mirror **Settings → Backup → Git**, per account. Give it a remote and a branch and Papyra commits and pushes your notes on a schedule, using a real git implementation rather than shelling out. This is the one to set up if you want the backup to happen without you remembering. It also gives you a full history of every note, browsable with any git tool. **It will never force-push over a diverged remote.** If the remote's history has moved apart from Papyra's — you pushed from elsewhere, or rewrote history — the sync stops and reports it rather than overwriting whatever is there. Resolve it by hand and it resumes. Push manually any time with **Sync now**. ## What to actually do For most people: keep the volume in whatever backs up the rest of the machine, and turn on the git mirror. The first protects you from losing the disk; the second from losing a note you wanted back. Add encrypted archives when the copy leaves your control. ## Restoring onto a fresh instance 1. Bring up Papyra with an empty volume so it initialises. 2. Stop the container. 3. Put the backed-up `/data` in place, including `.papyra/keys/`. 4. Start it again. On start-up Papyra reconciles disk against cache **before** it opens the port, so notes restored underneath it are picked up on the way up. You do not need to rebuild anything by hand, though **Settings → Data & Storage** will rebuild the search index if search looks stale afterwards. ## Trash is not a backup Deleted notes sit in Trash for the retention period — 30 days by default, configurable in **Settings → Data & Storage**, including "keep forever" and "delete immediately". Once retention expires the note is erased for good. Snapshots of overwritten text are a different mechanism again, kept per note and reachable from **File recovery** and **Time machine** in the note toolbar. Neither of those is a substitute for having the folder somewhere else. --- ## Importing your notes > Bringing a vault in from Obsidian or Google Keep, dragging in single files, or simply copying Markdown into the folder. Source: https://papyra.app/docs/importing/ ## The simplest import Copy `.md` files into `/data/users//notes/`. Papyra notices them and they appear. No import step, no processing — they were already in the right format. This is the right move for an Obsidian vault you have direct filesystem access to, and it preserves everything, because nothing is being converted. ## Obsidian **Settings → Data & Storage → Import → Obsidian**. Upload your vault and Papyra brings the notes across, keeping frontmatter and `[[wiki links]]`, which mean the same thing in both apps. Each note keeps its filename as its title and its last-modified time. If the note has a `modified:`/`updated:` or `created:` frontmatter date, Papyra uses that instead, because zip timestamps carry no time zone. Bookmarked (or starred) notes come in pinned. Attachments land in `media/`. The `.obsidian/` config folder and `.trash/` are skipped. Import runs as a background job with a progress bar. You can leave Settings and come back, and the bar will still be there. Only one import runs at a time. ## Google Keep Export from [Google Takeout](https://takeout.google.com), selecting Keep. Then **Settings → Data & Storage → Import → Google Keep** with the archive. Papyra maps what has an equivalent. Title and body carry over. Checklists become to-dos. Labels become tags. Pinned and archived state carry over. Colours map to the nearest Papyra swatch. The last-edited time becomes the note's last-modified time. Web links and images carry over too. Takeout does not include Keep's drag order, so notes are arranged newest-created first, as Keep shows them by default. Notes in Keep's trash and reminders are not imported. ## Importing the same archive again Re-importing never duplicates a note. Papyra recognises each note it imported before and handles it one of two ways: - **Identical** (same content, colour, pins, labels and last-modified): left alone. - **Changed since** (in the source, or edited here): overwritten with the imported version and all of its metadata. The version it replaces stays in the note's History. If you locked a note or moved it to Trash in Papyra, it keeps that state. When the import finishes, Papyra shows how many notes were new, updated, already identical or skipped. ## Drag a file in Drop a text file onto the note grid and it is imported as a note, immediately. Good for one-offs. ## After a large import One thing is worth doing once the dust settles, in **Settings → Data & Storage**: - **Rebuild the search index** if search does not immediately find imported notes. ## What to check afterwards Skim a handful of imported notes for: - **Attachments.** Images referenced by relative paths need those files present in `media/`; a copied-in vault should bring them along, an archive import may not. - **Frontmatter you rely on.** Keys Papyra does not own are preserved untouched, but it is worth confirming with one note that yours came through. - **Links.** `[[wiki links]]` resolve by note id, which is the filename. A note renamed during import will have links pointing at the old name. ## Going the other way Everything imported stays a plain file, so leaving is the same operation in reverse: copy the folder, or use **Settings → Backup → Export** for a zip. See [Backups](/docs/backups/). --- ## Writing, embeds and attachments > Slash commands, embedding any link — a map, a song, a page — and how pictures and documents look in a note and in its Markdown file. Source: https://papyra.app/docs/writing/ Everything you add to a note is plain Markdown in its file. This page covers the quick ways to add things, and what each one writes. ## Slash commands Type `/` at the start of a line (or after a space) and a menu of blocks opens under the caret — or above it, near the bottom of the note. Keep typing to filter it; arrow keys move, Enter inserts, Esc closes. | Type | To add | | --- | --- | | `/h1`, `/h2`, `/h3` | A heading | | `/bullet`, `/numbered`, `/check` | A list or a checklist | | `/quote`, `/code`, `/table`, `/rule` | A quote, a code block, a table, a divider | | `/image` | A picture from a link | | `/embed` | Any link: a map, video, song, document or web page | | `/youtube` | A YouTube video | | `/media` | Upload a picture, recording or video | | `/link`, `/date` | Link another note, insert today's date | The menu always fits inside the note: it scrolls when there are more commands than room. ## Embedding a link **Insert → Embed a link** (or `/embed`) takes the link you already have. You don't need to hunt for an "embed URL": - **Maps** — a Google Maps link (a place, a search, directions, or a `maps.app.goo.gl` share link) shows that place on a map. An **Apple Maps** share link (`maps.apple/p/…` or `maps.apple.com/…`) shows the same place too, and so does an OpenStreetMap link. A dropped pin opens at street level. - **Video and music** — YouTube, Vimeo, Loom, Dailymotion, TikTok, Twitch, Spotify and SoundCloud links play in the note, at their player's own shape. - **Work** — Google Docs, Sheets, Slides and Drive files, Figma, Miro, Canva, CodePen and CodeSandbox links show their live preview (to anyone the file is shared with — a private Google Doc asks the reader to sign in). - **Any other page** is shown in place when the site allows it. Many sites refuse to be shown inside another one; for those, Papyra adds a **link card** instead (the page's title, summary and picture) and tells you so. Short links are followed and pages are checked by **your** server, through the same guarded fetcher as link previews: public addresses only, nothing on your network. Click an embed to select it. Its toolbar aligns it, moves it a block up or down, removes it, and edits its caption (and, for a map or video, its link). Drag its edge to resize it. The toolbar always stays in view: above or below the embed, or inside it near the bottom of a long note. Rest the pointer on any embed and a preview of its page appears beside it (the video's own page, the place in Maps); click the preview to open the page. A link card works the same way: a click selects it rather than following it, and its preview (or Ctrl/⌘-click) opens it. Make a card narrower and it tidies itself: a shorter summary and a smaller picture, then just the title. Size, alignment and caption are saved with it: ```markdown ![[iframe:https://www.google.com/maps?q=Nikkawahama+Beach&output=embed|640x400]] ![[card:https://example.com/post|Example post|480]] ``` ## Moving things around Press on a picture, an attachment, a document, a link card or an embed and drag it up or down. A line shows where it will land, and the note scrolls when you drag near its top or bottom. Esc cancels, and Ctrl+Z undoes the move. Videos, recordings and a map you're using move by the small grip beside their top-left corner (the grip also works on a phone). Text isn't moved this way. From the keyboard, select the item and use **Move up** / **Move down** in its toolbar. ## Pictures Drop, paste or upload a picture and it appears straight away — the upload shows its progress over the picture itself, and the picture never blinks out while the stored copy loads. A picture from a link shows a soft loading frame until it arrives, and a small card with **Retry** if it can't. Size and alignment are part of the file: ```markdown ![[beach.jpg|640]] ![A gif|240](https://media.example.com/party.gif) ``` ## Documents A PDF, a spreadsheet, a slide deck, an archive — any file that isn't a picture or a recording — sits in the note like it does on your desktop: its icon and its name. Click it once and its toolbar appears beside it: what it is and how big, then **Preview** (PDFs open in your browser's viewer; text, CSV and code files as text), **Rename**, **Download**, **Replace** or move it. Double-click to open it — a file the browser can't show (a Word or Excel file) opens in a new tab, where your browser downloads it. **Rename** changes only the name shown in the note — the file keeps its stored name, and the name you chose is its alias in the Markdown: ```markdown ![[q3-report-411e00.pdf|Q3 report]] ``` A new upload is shown under the name it had on your computer. --- ## Sharing and mentions > Public links that expire, sharing with someone on your server, and what exactly a mention gives away. Source: https://papyra.app/docs/sharing/ ## Public links Open a note, use the share control, and create a link. Choose how it ends: - **On a date** — the link stops working then. - **After N views** — it burns out once used up. - **Never** — until you revoke it. Anyone with the link can read the note, with no account. A link can also grant editing. Expired and exhausted links are tidied up by a background job rather than lingering; you can revoke any link by hand at any time from the note's share menu. ## Sharing with someone on your server Share directly with another account and the note appears in their **Shared with me**. They get exactly what you granted — view or edit — and it ends when you revoke it. When the note links to other notes of yours, the share dialog offers **Also share the notes it links to**: all of them, or just the ones you tick. They go to the same person with the same access, so the links work for them as they do for you. ### What they can do with it A note shared with you sits on your desk like your own, with its owner's face (their initials until they add a picture) at the foot, and **Can view** when you can only read it. Hover over it for the same actions as your own notes: - **Pin** it, and give it **your own tags** — they organise your desk and filters, and nobody else's. The owner's tags stay the owner's. - **Make a copy** into your own notes, **copy its link**, or **download** it as a `.md` file. - With edit access, also change its **colour** — colour belongs to the note, so the owner and everyone else see it — and **share it onward** with someone else. It stays the owner's note: they see who you added (and who added them), and can change or remove that access like any other. Shared notes can be selected along with your own; the selection bar pins them, and leaves archiving, sharing and deleting to their owners. One copied link works for everyone: it opens the note for its owner and for anyone it is shared with, and tells anyone else it isn't shared with them. Either way, the note's card in your grid carries a badge showing it has left the vault, with the names on hover. This is on the card rather than two clicks away on purpose: "who else can read this one" is the question the whole product is built around. ## Mentions invite, access is the whole note Type `@` and a username. They get a notice in their Inbox, and Papyra asks whether to share the note with them: - **Can edit** (the default) — they open the note and write in it with you. - **Can view** — they read it, and can ask you for edit access. - **Don't share** — they see who mentioned them and when, nothing of the note, and a **Request access** button. Access is always the whole note or nothing. Requests land in your Inbox, where you approve (as editor or viewer) or decline. If the note is later deleted or locked, the inbox entry says so plainly. ## Locked notes never leave A Vault note is **never shareable and never delivers a mention**, whatever you type in it. There is no setting to override this. If you need to share something that lives in a locked note, move it to an unlocked one first. ## Wiki links are not sharing `[[double bracket]]` links and **Linked mentions** are navigation within your own vault. Linking to a note gives nobody access to it. In a note shared with you, a link opens the note it points to when its owner shared that one with you too (at whatever access they chose); otherwise Papyra says it isn't shared with you, and whom to ask. ## Smart collections A collection is a saved rule, not a folder: everything tagged `work` and pinned, or anything whose text mentions pricing. Build the rule in the UI and the collection stays current as you write. Notes are not moved or copied into a collection, so a note can be in several at once, or none. Collections are private to you and are not shared by sharing a note. To pass one on, use the share button on its card in **Collections**. Only the name and rules travel, never notes, so the rules run against the receiver's own notes. **Send** it to someone on your server (it waits in their notifications until they add it), or **copy it as JSON** and paste it into **Collections → Import** on any Papyra. A name they already use gets a number. ## Revoking Everything is revocable, and revocation is immediate: - A link — the note's share menu. - A person's access — the same place. - A mention — the recipient dismisses it from their Inbox, which drops the grant. Once revoked, the next request from that link or person is refused. There is no grace period, and nothing is cached on their side that would keep working. --- ## People, invites and SSO > Adding people to your server, sending invites and password resets, and pointing Papyra at an identity provider. Source: https://papyra.app/docs/accounts/ The first account created on a fresh instance is the administrator. There is no default account and no default password. ## Adding people **Settings → Manage users**, as an administrator. You can: - **Create an account** with a temporary password. - **Reset a password** when someone is locked out. - **Generate a one-time recovery link** to hand over directly. - **Delete an account**, which removes that person's vault. An account an admin created or reset must choose a new password before the API will let it do anything else. A password somebody else chose is a password somebody else knows, so the rest of the app stays closed until it is changed. ## Email invites and resets Without SMTP configured, you create accounts by hand and pass on the temporary password or recovery link yourself. That is a perfectly reasonable way to run a server for your family. With SMTP configured (**Settings → Email**), Papyra can email an invite and handle "forgot password" itself. There is a **Send test email** button — use it, because SMTP misconfiguration otherwise only shows up when somebody is already locked out. The SMTP password is write-only: the server reports whether one is stored, never what it is. ## Single sign-on **Settings → SSO**, as an administrator. Papyra speaks OIDC and provisions an account on first successful login. You need, from your identity provider: - **Authority URL** — the issuer, e.g. `https://id.example.com/realms/main` - **Client ID** and **Client secret** - A **redirect URI** registered at the provider, pointing back at Papyra It is configured from the UI at runtime and takes effect **without a restart** — you are not editing a config file inside a container to change an identity provider. If the provider cannot be reached, Papyra says so in words and names the setting to check, rather than returning a bare gateway error. > **Honest status:** SSO is implemented and its configuration flow is tested, > but a full round trip against a live identity provider is the one thing the > project has not been able to verify itself. If you turn it on, keep a > password-based administrator account so you cannot lock yourself out. ## Personal access tokens **Settings → API Keys** creates tokens for scripts and integrations. A token inherits the permissions and the folder of the account that created it — it can reach exactly that person's notes and nothing else. Tokens are stored hashed, so Papyra shows the value once, at creation, and never again. Send it as `X-API-Key` or `Authorization: Bearer`. See the [API reference](/api/). ## Privacy between accounts Each account's notes live in their own directory, and every path is checked against that directory before anything is read or written. An administrator can manage accounts — create, reset, delete — but administration is not readership: there is no "view all notes" screen, because there is no code path that would produce one. The exception is deletion. Deleting an account removes its vault. --- ## Security posture > What Papyra does to protect your notes, what it deliberately does not claim, and how to report a problem. Source: https://papyra.app/docs/security/ Papyra is self-hosted software holding people's private writing. This page says plainly what it does and does not do, so you can decide how to run it. ## Accounts and sessions - Passwords are hashed with **BCrypt**. - Sign-in is **throttled per account** — ten attempts per fifteen minutes — and the auth endpoints are additionally rate-limited per IP. - Sessions are cookie-based: `HttpOnly`, `SameSite=Lax`, and `Secure` in production unless you explicitly turn that off for a private network. - The signing keys live on the data volume, so restarting or upgrading the container does not silently sign everybody out. - An account created or reset by an administrator must set its own password before the API will do anything else for it. ## Isolation between accounts Each account's notes live in their own directory, and every path the API resolves is checked against that directory before any read or write. A crafted note id cannot walk out into another account's vault; the request is refused. This is enforced in the storage layer rather than by filtering results, so it holds on code paths nobody thought about specifically. Personal access tokens inherit the same boundary as the account that made them. Administrators can manage accounts but cannot read other people's notes — there is no screen for it because there is no code path that would produce one. ## Vault notes A note marked secure has its **body withheld by the server**. The API returns metadata and an empty body until a passkey unlock issues a short-lived token, which the server checks before sending any text. The blur in the UI is cosmetic. The lock is that the bytes were never sent. Vault notes are never shareable and never deliver a mention. ## Tokens and secrets - Personal access tokens are stored **hashed** and shown once, at creation. - API keys for OpenAI/Anthropic and the SMTP password are **write-only** over the API: the server reports whether one is set, never its value. - Outbound webhooks are **HMAC-signed** so the receiver can verify the delivery came from your instance. ## Transport and headers Security headers and a Content-Security-Policy are set on every response. Forwarded headers are only trusted from proxies you have explicitly listed, so an unlisted proxy cannot spoof a client address past the rate limiter. ## What Papyra does not claim - **Ordinary notes are not encrypted at rest.** They are plain files on your disk, which is the entire point of the design. Use full-disk encryption if you need it. Encrypted *backups* are a separate, supported thing. - **Locked notes are encrypted at rest — with a key on the same server.** Their files are sealed with a per-account key ring kept in `.papyra/`, so a copied notes folder or a plain git mirror does not expose them. Someone with the whole disk, keys included, could still open them. Lose the keys and nobody can — back up the whole `/data` folder, hidden folders included. - **No third-party audit** has been performed. - **This is young software.** 1.0 is the first stable release; it has been in daily use, but not for years. Keep backups. ## The developer portal is reachable in production Your instance serves live API documentation at `/docs`, deliberately, so self-hosters have real documentation for their own server. It documents the same authenticated endpoints and grants nothing — the reader supplies their own personal access token to try anything. ## Reporting a problem Report security issues privately through [GitHub's security advisories](https://github.com/lyfie-org/papyra/security/advisories/new) rather than opening a public issue. Papyra is GPL-3.0. All of the above is claims you can check against the source rather than take on trust. --- ## Troubleshooting > The failures people actually hit, what causes them, and what to do — starting with the sign-in loop. Source: https://papyra.app/docs/troubleshooting/ ## Signing in bounces me straight back to the login screen Much the most common problem. You are reaching Papyra over plain HTTP at something other than `localhost`, and the browser is refusing to keep the session cookie. On a private network: ```yaml PAPYRA_ALLOW_INSECURE_COOKIES: "true" ``` Otherwise set up [HTTPS](/docs/https/). There is nothing wrong with your password. ## The sidebar says "Server Offline" but the app works The realtime connection cannot open, usually because a reverse proxy is not forwarding websockets. Add the upgrade headers — see [HTTPS and reverse proxies](/docs/https/). Papyra keeps working: it falls back to periodic refreshes. What you lose is live updates across devices and the immediate offline signal. ## Everyone got signed out after an upgrade `/data/.papyra/keys/` was lost. That directory holds the keys your session cookies are signed with; without it every existing session is unverifiable. It lives on the data volume so this does not happen. If you are restoring from backup, make sure that directory is in it. Nothing is damaged — everyone signs in again. ## Search misses a note I can see Rebuild the full-text index in **Settings → Data & Storage**. It is derived data; rebuilding cannot lose anything. If search works but looks cruder than usual and results are labelled partial, Papyra is matching locally in your browser because the server or index was unavailable — which is the fallback working as intended. ## Notes I edited elsewhere have not appeared Papyra watches the folder, so this should be immediate. If not: - Check you edited under `/data/users//notes/` and not a stale copy. - Some network filesystems do not emit change events. Restarting Papyra forces a full reconcile — it compares disk against cache before opening the port. ## A conflict banner appeared Two versions of the same note were written independently, usually by a syncing tool. Papyra kept both rather than picking a winner. Open the resolver, compare the two panes, choose. To make it rarer, exclude `.papyra/` from whatever you are syncing — per-instance state syncing between machines creates conflicts that mean nothing. ## The files are owned by root Set `PUID` and `PGID` to your own user and restart: ```yaml PUID: "1000" PGID: "1000" ``` Find yours with `id -u` and `id -g`. This only matters if you mounted a host folder rather than using the named volume. ## Passkeys are not offered Passkeys require a secure context. They do not work over plain HTTP outside `localhost`, and no Papyra setting changes that — it is the browser's rule. If they worked and then stopped, check whether the hostname changed. Passkeys are bound to a domain, and `PAPYRA_WEBAUTHN_DOMAIN` must be the bare hostname — no scheme, no port. ## Git backup stopped Papyra refuses to force-push over a diverged remote. If history moved apart — you pushed from elsewhere, or rewrote it — the sync stops and says so rather than overwriting. Reconcile the remote by hand and it resumes. ## Something else Check the container logs first: ```bash docker compose logs -f papyra ``` Then open an issue at [github.com/lyfie-org/papyra](https://github.com/lyfie-org/papyra/issues) with what you did, what happened, and the relevant log lines. Report anything security-related [privately](/docs/security/) instead. --- ## Contributing to Papyra Thanks for wanting to help. Papyra is young, so every bug report, idea and pull request genuinely shapes it. ## Ways to help — no code needed - **Use it and tell us what breaks.** Try the [live demo](https://papyra.app/demo/) or run your own copy, then [open an issue](https://github.com/lyfie-org/papyra/issues/new) with what you did, what you expected and what happened. Screenshots help. - **Ask for what's missing.** Feature ideas are issues too. Say what you are trying to do, not only the button you want. - **Improve the docs.** Anything that confused you on [papyra.app/docs](https://papyra.app/docs/) will confuse the next person. The pages live in `papyra.app/src/content/docs/`. - **Star the repo and tell a friend.** Honestly, this helps more than you'd think. Found a security problem? Please don't open a public issue — report it privately through [GitHub's security advisories](https://github.com/lyfie-org/papyra/security/advisories/new). ## Building on Papyra Want to script your notes, connect another tool or build a plugin? Everything the web app does goes through a documented REST API, with personal API keys and signed webhooks: see the [API reference](https://papyra.app/api/). ## Before you write code For anything bigger than a small fix, **open an issue first** so we can agree on the approach before you spend an evening on it. Papyra was largely vibe-coded with AI assistants, so AI-assisted pull requests are welcome. The bar is the same either way: you have read the change, run it, and tested it. ## Setting up You need the [.NET 10 SDK](https://dotnet.microsoft.com/download), Node 22 and pnpm 11. ```bash git clone https://github.com/lyfie-org/papyra.git cd papyra pnpm install pnpm dev # API on :5220, web app on :5173, live-editing engine on :5231 ``` | Folder | What it is | |---|---| | `papyra.api/` | .NET 10 Minimal API, plus xUnit and black-box edge tests | | `papyra.web/` | The React 19 + Vite web app (also built as the in-browser demo) | | `papyra.collab/` | The live-editing engine, run by the API as a child process | | `papyra.app/` | This website: Astro, docs and the demo host | ## A few rules the code lives by - **The `.md` files are the source of truth.** SQLite and the search index are caches that can be rebuilt from the folder. Never make them the only copy of anything. - **Writes are atomic** — temp file, flush, replace. A crash must never leave half a note. - **Papyra's own state lives in `.papyra/`**, never inside the notes folder. - **Never move someone's caret.** A remote change must not hijack the editor. - **Design tokens only.** Colours come from `var(--token)`; fonts are Marcellus, Sora and Roboto Mono; everything meets WCAG AA in light and dark. ## Before you open a pull request Run the checks for whatever you touched: ```bash # API dotnet build Papyra.slnx dotnet test papyra.api/tests/Papyra.Tests/Papyra.Tests.csproj # Web app (from papyra.web/) pnpm run build # type-check + bundle — catches more than tsc --noEmit pnpm test pnpm run check:a11y # WCAG 2.2 AA in real Chromium (CI fails on any violation) # Live-editing engine (from papyra.collab/) pnpm run typecheck && pnpm run lint && pnpm test # Website (from papyra.app/) pnpm run build ``` Then: 1. Branch from **`development`** and open your pull request against **`development`** — `main` only receives releases. 2. Use [Conventional Commit](https://www.conventionalcommits.org) prefixes (`feat:`, `fix:`, `docs:`, `chore:`…). The changelog is generated from them. 3. Keep it focused: one change per pull request, with a sentence on *why*. 4. If it changes what a user sees, include a screenshot. ## Part of Lyfie Papyra is a [Lyfie](https://www.lyfie.org) project, alongside its editor, [luthor](https://www.luthor.fyi). Other projects that could use a hand are listed at [lyfie.org/contribute](https://www.lyfie.org/contribute/). ## License Papyra is [GPL-3.0](https://github.com/lyfie-org/papyra/blob/main/LICENSE). By contributing, you agree your work is released under the same license.