Self-hosted, database-less markdown notes.
43

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.
.md file you can open,
edit, or move with any other tool, even while the app is running.md file) back in, never
overwriting on a title collisionNotespice 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.
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 (
localhostis exempt for testing), so offline support and "Add to Home Screen" will only work once this is served over TLS on a real domain.
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
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
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
chowncommand 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.)
The image is built on the maintainer's own computer and published to
Docker Hub as
lightmorphic/notespice
with each release.
| Variable | Required | Default | Purpose |
|---|---|---|---|
NOTES_USERNAME | no | admin | Login username |
NOTES_PASSWORD | yes | (none) | Login password (min. 8 characters). Hashed with Argon2id in memory at startup; never stored or logged in plaintext. |
NOTES_DIR | no | /notes | Where .md files and their files/ attachments live. This is the actual vault. |
NOTES_DATA_DIR | no | /data | Where app-only state lives (currently just the recently-viewed list). Not notes. |
NOTES_PORT | no | 8080 | Port to listen on |
NOTES_INSECURE_COOKIES | no | false | Set to true only for local testing over plain http://. Never set this in production; it removes the Secure flag from the session cookie. |
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:
files/, see
Data model)[^1] / [^1]: ...), with a collected footnotes section
rendered at the bottom of the note> <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 textEvery 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.
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.
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:
.zip shape. Every .md file at its root becomes a
note, everything under files/ becomes an attachment.md file. Its filename (minus the extension)
becomes the note titleImport 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.)
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.
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.
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
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.
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.
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.
Content type
Image
Digest
sha256:4e849d478…
Size
35.2 MB
Last updated
about 4 hours ago
docker pull lightmorphic/notespice