Sign inSign up

lightmorphic/notespice

By lightmorphic

•Updated about 4 hours ago

Self-hosted, database-less markdown notes.

Image
0

43

lightmorphic/notespice repository overview

Notespice logo

⁠Notespice

A self-hosted, database-less notes app. Every note is a plain markdown file on disk (no database, ever) with a Rust backend, a full GitHub Flavored Markdown toolbar, and an installable PWA frontend.

See CHANGELOG.md⁠ for version history.

⁠Features

  • Clean, minimal interface with a full GitHub Flavored Markdown toolbar: headings, lists, tables, footnotes, GitHub-style callouts, the works (see GitHub Flavored Markdown support⁠)
  • WYSIWYG editor with a one-click raw-markdown toggle. The converter behind it is small and hand-written, not a third-party library loaded from a CDN, so there's nothing external to version-mismatch or break
  • No database: every note is just a .md file you can open, edit, or move with any other tool, even while the app is running
  • Images, uploads, and file attachments, stored alongside your notes and referenced by plain markdown links
  • Full-text search, plus instant name-filtering as you type
  • Sidebar shows your last 10 viewed notes first, not just last-edited
  • Undo/redo
  • Export a single note as Markdown or PDF, or everything as one dated zip; import that same zip (or a loose .md file) back in, never overwriting on a title collision
  • Installable PWA that works offline for the app shell, with "Add to Home Screen" on mobile or desktop
  • Dark throughout, with no theme setting to configure
  • Argon2id password hashing, per-IP login rate limiting, and a handful of other deliberate security choices (see Security notes⁠)
  • Self-hosted Manrope⁠ typeface: no font CDN, no external font request of any kind

⁠Storage

Notespice stores notes as individual markdown files in one directory. No database, no proprietary format: the filename is the note title, and attachments live in a files/ subfolder right alongside. That's the entire data model. ls the directory, open a note in any text editor, back it up with rsync, or stop using this app entirely; nothing is ever locked away in a format only Notespice understands.

See Data model⁠ below for the full detail, including how search, recently-viewed tracking, and attachments all fit into that same single directory.

⁠Quick Start

⁠Running locally without Docker

Requires a reasonably current stable Rust toolchain. Install via rustup⁠ if your OS package manager's version is old.

curl -L -o notespice.tar.gz https://notespice.buildmorphic.com/code/latest.tar.gz
tar xzf notespice.tar.gz
cd notespice-v*
cargo build --release
NOTES_PASSWORD=changeMe123 NOTES_DIR=./notes NOTES_DATA_DIR=./appdata ./target/release/notespice

Open http://localhost:8080⁠. Notes are written to ./notes, as .md files, with attachments under ./notes/files. App-only state (the recently-viewed list) lives separately under ./appdata.

Note: service workers require HTTPS (localhost is exempt for testing), so offline support and "Add to Home Screen" will only work once this is served over TLS on a real domain.

⁠Using Docker
  1. Pull from Docker Hub (see Image publishing⁠)

    docker pull lightmorphic/notespice:latest
    sudo mkdir -p /opt/media/notes /opt/notespice
    sudo chown -R 1000:1000 /opt/media/notes /opt/notespice
    docker run -p 8080:8080 \
      -e NOTES_PASSWORD=changeMe123 \
      -v /opt/media/notes:/notes \
      -v /opt/notespice:/data \
      lightmorphic/notespice:latest
    
  2. Or build locally

    docker build -t notespice .
    sudo mkdir -p /opt/media/notes /opt/notespice
    sudo chown -R 1000:1000 /opt/media/notes /opt/notespice
    docker run -p 8080:8080 \
      -e NOTES_PASSWORD=changeMe123 \
      -v /opt/media/notes:/notes \
      -v /opt/notespice:/data \
      notespice
    
  3. Docker Compose

    services:
      notespice:
        image: lightmorphic/notespice:latest
        container_name: notespice
        restart: unless-stopped
        environment:
          NOTES_USERNAME: "admin"
          NOTES_PASSWORD: "changeMe!"
        volumes:
          - /opt/media/notes:/notes
          - /opt/notespice:/data
        ports:
          - "8080:8080"
    
    sudo mkdir -p /opt/media/notes /opt/notespice
    sudo chown -R 1000:1000 /opt/media/notes /opt/notespice
    docker compose up -d
    

Note: the container runs as a non-root user (UID/GID 1000), not root. After creating the data directory (or if you're upgrading an existing install), run the chown command above so the container can actually write to the mounted folder. Without it, Notespice will fail to create or update notes.

Open http://localhost:8080⁠. Notes (*.md) and attachments (files/) end up under /opt/media/notes, and that's the one directory worth backing up. /opt/notespice holds only app-internal state (currently just the recently-viewed list used for sidebar ordering), not notes. It's safe to lose, and kept separate on purpose so it's never mixed in with your actual data. The docker-compose.yml itself can live wherever you keep your other compose stacks; its own location is independent of where either of these directories is.

Put this behind a TLS-terminating reverse proxy (nginx, Caddy, Traefik, or Tailscale Serve) for anything beyond local testing. The Secure cookie flag (on by default) requires the browser to have reached it over HTTPS, and PWA installability requires it too.

After updating the image:

docker compose pull
docker compose down
docker compose up -d

(docker compose up -d alone does not re-pull a cached :latest tag.)

⁠Image publishing

The image is built on the maintainer's own computer and published to Docker Hub as lightmorphic/notespice⁠ with each release.

⁠Environment variables

VariableRequiredDefaultPurpose
NOTES_USERNAMEnoadminLogin username
NOTES_PASSWORDyes(none)Login password (min. 8 characters). Hashed with Argon2id in memory at startup; never stored or logged in plaintext.
NOTES_DIRno/notesWhere .md files and their files/ attachments live. This is the actual vault.
NOTES_DATA_DIRno/dataWhere app-only state lives (currently just the recently-viewed list). Not notes.
NOTES_PORTno8080Port to listen on
NOTES_INSECURE_COOKIESnofalseSet to true only for local testing over plain http://. Never set this in production; it removes the Secure flag from the session cookie.

⁠GitHub Flavored Markdown support

Notespice targets full GitHub Flavored Markdown⁠, plus the callout/alert syntax GitHub's own renderer supports on top of that spec. The toolbar covers all of it:

  • Headings (1-6), bold, italic, strikethrough, inline code
  • Bullet, numbered, and checkbox (task) lists, with indent/outdent for nesting
  • Blockquotes, fenced code blocks, horizontal rules
  • Tables
  • Links, images (by URL or upload), and generic file attachments (inserted as a link, stored under files/, see Data model⁠)
  • Footnotes ([^1] / [^1]: ...), with a collected footnotes section rendered at the bottom of the note
  • GitHub-style callouts (> <p data-quote-type="note">Note</p>, > <p data-quote-type="tip">Tip</p>, > <p data-quote-type="important">Important</p>, > <p data-quote-type="warning">Warning</p>, > <p data-quote-type="caution">Caution</p>) rendered with the same colored-box treatment GitHub.com uses, not just as plain blockquote text

Every file Notespice writes is plain, spec-compliant markdown. Open it on GitHub, in another editor, or in a terminal, and it reads correctly regardless of whether Notespice is involved at all. The editor is a small hand-written markdown ⇄ HTML converter built specifically for this app: no external editor library, no CDN dependency, nothing to version-mismatch or break. It only implements the GFM subset this toolbar exposes (listed above); anything outside that (raw HTML embeds, non-standard extensions) round-trips as plain text rather than being specially rendered.

⁠Security notes

Found a vulnerability? See SECURITY.md⁠ for how to report it. The notes below are about what's already built in, not how to disclose something new.

  • Passwords are hashed with Argon2id; only the hash is ever kept in memory, and it's never written to disk or logged.
  • Sessions are opaque random tokens held server-side. The cookie itself carries no information, so nothing meaningful leaks if it's ever captured outside of TLS.
  • Login attempts are rate-limited per source IP (8 attempts per 15 minutes) to blunt brute-force attempts.
  • Every note title is passed through an allow-list sanitizer before it ever touches the filesystem, closing off path traversal (requests that try to climb out of the notes folder) at the one place all note I/O goes through.
  • The container runs as a non-root user, and the base image's OS packages are patched at every build (see the CI workflow's weekly scheduled rebuild).
  • Request bodies are capped at 25MB (covering the 20MB per-file attachment cap plus multipart overhead), and zip imports are additionally capped on decompressed size, 20MB per entry and 200MB per archive, so a crafted "zip bomb" can't exhaust the server.

⁠Export / Import

The sidebar footer has four icon buttons: export this note, export everything, import, and log out.

Export this note downloads just the note that's open, in a choice of format: Markdown (.md), the note's own file exactly as it's stored, built and downloaded entirely client-side (no server request); or PDF, which goes through the browser's own print-to-PDF rather than a rendering library - nothing loaded from anywhere else to make it work.

Export all downloads every note and attachment as one zip, named YYYY-MM-DD_notes.zip: notes at the root as .md files, attachments under files/. It's the same shape either way, so an export is also a valid import.

Import accepts either:

  • That same .zip shape. Every .md file at its root becomes a note, everything under files/ becomes an attachment
  • A single loose .md file. Its filename (minus the extension) becomes the note title

Import never overwrites an existing note. A title that already exists gets a (1), (2), etc. suffix instead: importing a note called note-name when note-name.md already exists produces note-name(1).md; import it again and you get note-name(2).md, and so on. Re-importing an export you already have, in other words, adds a duplicate copy rather than silently replacing anything. (Imported attachments use Notespice's regular file-upload collision handling instead, a -2, -3, etc. suffix, since that's the same code path as a normal upload through the editor.)

⁠Note list order

Pinned notes come first, under a "Pinned" heading. Click the pin icon on a sidebar row (it appears on hover on desktop, and is always shown on touch devices) to pin or unpin a note; pinning has no effect on the note's content or its file on disk.

Below the pinned notes, the sidebar shows your last 10 viewed notes, most-recently-opened at the top, not last-edited. Opening a note you don't change still brings it to the top; editing isn't required. Reopening a note already in that list moves it back to the top rather than duplicating it. Every other note (anything outside the last 10 viewed) falls back to last-modified order underneath.

Both are tracked as small JSON files in NOTES_DATA_DIR: .recent.json is an array of up to 10 titles, most-recent-first; .pinned.json is an array of pinned titles. Both are disposable app state, not a note, which is exactly why they live in a separate directory from the notes themselves rather than mixed in with your vault: deleting .recent.json just resets the sidebar to modified-time order, and deleting .pinned.json just unpins everything — nothing else is affected, and neither file is part of search, export, or the note list itself.

⁠Data model

Every note is <NOTES_DIR>/<title>.md, a plain UTF-8 markdown file. Anything inserted into a note (images, PDFs, any other attachment) is uploaded to <NOTES_DIR>/files/<name> and referenced from the note by a relative link, so the whole vault (text and attachments together) is still just one bind-mounted volume: NOTES_DIR is the only directory you ever need to back up. NOTES_DATA_DIR is a separate, smaller directory for app-only state (see Note list order⁠), deliberately not part of the vault, since it isn't your data.

There is no database, no hidden index file that matters (the search index lives in memory only and rebuilds from these files on every start), and no proprietary formatting. You can add, edit, or delete .md files, or drop files directly into files/, while the app is stopped. You can even do it while it's running, though you'll need to restart to pick up out-of-band changes, since the index isn't watching the filesystem.

Attachment filenames go through the same allow-list sanitizer as note titles before ever touching disk, and duplicate uploads are disambiguated with a -2, -3, and so on suffix rather than overwriting. Fetching an attachment (GET /api/files/<name>) requires the same session cookie as everything else; nothing is reachable by a logged-out visitor just because it's rendered as an <img> tag rather than fetched with JavaScript. Uploads are capped at 20MB per file.

⁠Project Structure

notespice/
├── src/                      # Rust backend (axum)
│   ├── main.rs
│   ├── auth.rs               # password hashing, sessions, rate limiting
│   ├── handlers.rs           # HTTP route handlers
│   ├── store.rs              # note/attachment/recent-views file I/O
│   └── search.rs             # in-memory inverted-index search
├── static/                   # Frontend: plain HTML/CSS/JS, no build step
│   ├── index.html
│   ├── app.js
│   ├── style.css
│   ├── manifest.json         # PWA manifest
│   ├── sw.js                 # service worker (app shell only, no note data)
│   ├── icons/
│   ├── fonts/                # self-hosted Manrope (variable weight)
│   └── images/                # Lightmorphic badge logos
├── tests/
│   └── e2e-roundtrip.js      # 58-scenario Writer<->Markdown suite (real browser)
├── docs/
│   └── logo.png
├── Cargo.toml
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── .gitignore
├── CHANGELOG.md
├── SECURITY.md
└── LICENSE

⁠Development

Keep it simple. This app exists specifically to be small enough to read top to bottom: no frontend build step, no database, no dependency that isn't earning its place. If a change makes something harder to understand without a clear payoff, it's probably the wrong change.

The Writer/Markdown converter is guarded by tests/e2e-roundtrip.js: 58 scenarios covering the full supported GFM feature set in both directions, plus real toolbar and keyboard interactions, driven against the actual app in a real browser (Playwright + Chromium; see the file header for how to run it). Any change touching the editor, the converter, or Enter/paste handling should keep that suite at 58/58 before shipping.

⁠License

Copyright (C) 2026 Lightmorphic Ltd.

Notespice is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 3, as published by the Free Software Foundation.

It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the LICENSE⁠ file for the full text, or https://www.gnu.org/licenses/⁠.

Releases up to and including 1.8.24 were published under the MIT Licence. That grant is irrevocable, so those versions remain available on those terms; everything from the next release on is GPL v3.

⁠Disclaimer

This software is provided "as is", without warranty of any kind, express or implied. See the LICENSE file for the exact legal terms. You use it entirely at your own risk.

Comments are stripped from the published code to keep it lean.

Tag summary

Content type

Image

Digest

sha256:4e849d478…

Size

35.2 MB

Last updated

about 4 hours ago

docker pull lightmorphic/notespice