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.
β οΈ Vibe coded with Claude.
See CHANGELOG.md for version history.
- β¨ 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
- ποΈ No database β every note is just a
.mdfile 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
- π€ One-click export to a dated zip; import that same zip (or a loose
.mdfile) back in, never overwriting on a title collision - π± Installable PWA β works offline for the app shell, "Add to Home Screen" on mobile or desktop
- π Dark/light mode, following system preference
- π Argon2id password hashing, per-IP login rate limiting, and a handful of other deliberate security choices (see Security notes)
- π€ Self-hosted Inter typeface β no Google Fonts CDN, no external font request of any kind
Notespice stores notes as individual markdown files in one directory β
no database, no proprietary format. The filename is the note title.
Attachments live in a files/ subfolder right alongside them. 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.
git clone https://github.com/FOSSCharlie/notespice.git
cd notespice
cargo build --release
NOTES_PASSWORD=changeMe123 NOTES_DIR=./notes NOTES_DATA_DIR=./appdata ./target/release/notespiceOpen 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 GitHub Container Registry (after the Actions workflow has published it at least once β see Automatic image publishing)
docker pull ghcr.io/fosscharlie/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 \ ghcr.io/fosscharlie/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: ghcr.io/fosscharlie/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 β 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,
safe to lose, 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.)
.github/workflows/docker-publish.yml builds and pushes the image to
ghcr.io/fosscharlie/notespice on every push to main (plus a weekly
scheduled rebuild, so OS-level security patches keep landing even
without a code change), using the repo's built-in GITHUB_TOKEN β no
extra secrets needed. Make sure the resulting package is set to public
in the repo's Packages tab if you want to docker pull it without
authenticating.
| Variable | Required | Default | Purpose |
|---|---|---|---|
NOTES_USERNAME |
no | admin |
Login username |
NOTES_PASSWORD |
yes | β | 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 β 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:
- 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, upload, or drag/drop), and generic file
attachments (inserted as a link, stored under
files/β see Data model) - Footnotes (
[^1]/[^1]: ...) - GitHub-style callouts β
> [!NOTE],> [!TIP],> [!IMPORTANT],> [!WARNING],> [!CAUTION]
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. One honest caveat: the WYSIWYG view doesn't have bespoke colored-box styling for callouts, or a collected footnotes-section rendering, in the same visual style GitHub.com uses β those are the two features here newer than core GFM, and Notespice treats them as correct, valid text (a callout is just a specially-marked blockquote; a footnote reference is just bracket-caret text) rather than building custom rendering for GitHub's specific visual treatment of them. The underlying file is complete and correct either way.
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
(
../../etc/passwd-style requests) 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 5MB to blunt trivial resource-exhaustion attempts.
Export (sidebar β "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 (sidebar β "Import") accepts either:
- That same
.zipshape β every.mdfile at its root becomes a note, everything underfiles/becomes an attachment - A single loose
.mdfile β 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.)
The sidebar shows your last 10 viewed notes first, 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.
This is tracked in a small .recent.json file in NOTES_DATA_DIR β
plain JSON, an array of up to 10 titles, most-recent-first. It's
disposable app state, not a note, which is exactly why it lives in a
separate directory from the notes themselves rather than mixed in with
your vault: deleting it just resets the sidebar to modified-time order,
nothing else is affected, and it's excluded from search, export, and
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 (or even 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, β¦ 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 Inter
βββ docs/
β βββ logo.png
βββ Cargo.toml
βββ Dockerfile
βββ docker-compose.yml
βββ .dockerignore
βββ .gitignore
βββ CHANGELOG.md
βββ SECURITY.md
βββ LICENSE
βββ .github/workflows/docker-publish.yml
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.
Notespice is free software, licensed under the MIT License. See the LICENSE file for the full text.
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.
