Skip to content

FOSSCharlie/notespice

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

3 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

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.

⚠️ Vibe coded with Claude.

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
  • πŸ—‚οΈ 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
  • πŸ“€ One-click export to a dated zip; import that same zip (or a loose .md file) 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

Storage

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.

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.

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/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 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
  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: 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 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 β€” 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.)

Automatic image publishing

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

Environment variables

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.

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

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 (../../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 / Import

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

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.

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

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 Inter
β”œβ”€β”€ docs/
β”‚   └── logo.png
β”œβ”€β”€ Cargo.toml
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ docker-compose.yml
β”œβ”€β”€ .dockerignore
β”œβ”€β”€ .gitignore
β”œβ”€β”€ CHANGELOG.md
β”œβ”€β”€ SECURITY.md
β”œβ”€β”€ LICENSE
└── .github/workflows/docker-publish.yml

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.

License

Notespice is free software, licensed under the MIT License. See the LICENSE file for the full text.

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.

About

Notes without the nonsense.

Resources

License

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors