A self-hosted, Obsidian-like notes application, written in Rust front to back and created with Claude Code.
Your notes are ordinary markdown files on the server's filesystem. Postgres
holds a derived index — the link graph, tags, full-text search — which can be
thrown away and rebuilt from those files at any time. The editor is rich-text by
default, so someone who has never written markdown can use it, while what lands
on disk is plain .md that grep, git and every other tool understand.
- Rich-text editing, with a toggle to raw markdown. Headings from
#, a/command menu, tables, task lists, code blocks, and```mermaidblocks that render as diagrams. [[Wikilinks]]with autocomplete, backlinks with context, and a force-directed graph view. Links to notes that do not exist yet are drawn differently, and clicking one offers to create it.- Typed links.
[[contradicts::Budget]]records why one note points at another. It behaves as an ordinary link everywhere, and the graph labels it. - Suggested links. Dashed edges between notes that are about the same thing without linking to each other, from an embeddings model that the example deployments run for you.
- Renames that break nothing. Moving a note rewrites every
[[link]]to it, preserving each author's style — a bare[[Budget]]stays bare. - Full-text search, with a trigram fallback so typos and partial words still find things. Attachments are stored beside your notes.
- External edits. Edit over SSH,
git pulla vault, restore a backup — a filesystem watcher notices and reindexes without a restart. - Works with the server unreachable. Notes you have opened stay editable, changes queue, and a collision on reconnect is a question rather than a policy.
- Installs on a phone as a progressive web app, with the three-pane layout collapsing to one below 820px.
- Runs air-gapped, and multi-user, via Authelia (OIDC) or a local password file.
git clone https://github.com/joshjowen/go-notes && cd go-notes
podman compose -f deploy/docker-compose.local-auth.yml up --build
# In another terminal, create an account:
podman exec -it go-notes go-notes user add joshOpen http://localhost:8080. docker compose works identically.
Your notes appear as markdown in deploy/data/notes/josh/; set NOTES_DIR in
deploy/.env to put the vault somewhere you have chosen.
Three containers start: the app, Postgres, and a BGE-small-en-v1.5 model that
provides the suggested links — sized for a CPU-only, NUC-class host rather than
a workstation; see the comment beside the embeddings service in
deploy/docker-compose.yml for what that trade-off costs against BGE-base.
That last one downloads roughly 130 MB of weights the first time and caches
them in a volume — nothing waits for it, so the app is usable immediately. Set
EMBEDDINGS_ENABLED=false in deploy/.env to do without it.
deploy/docker-compose.yml adds Caddy for TLS and Authelia for single sign-on.
cp deploy/.env.example deploy/.env
$EDITOR deploy/.env # domains, secrets
$EDITOR deploy/caddy/Caddyfile # your domain names
$EDITOR deploy/authelia/configuration.yml # your domain namesAuthelia needs three secrets generated first:
A="podman run --rm docker.io/authelia/authelia:4 authelia"
# The OIDC signing key, written into deploy/authelia/oidc.key
podman run --rm -v ./deploy/authelia:/out:z docker.io/authelia/authelia:4 \
sh -c 'authelia crypto pair rsa generate --directory /out && mv /out/private.pem /out/oidc.key'
# The client secret: plaintext into deploy/.env as OIDC_CLIENT_SECRET, digest
# into configuration.yml as client_secret.
$A crypto hash generate pbkdf2 --variant sha512 --random --random.length 72
# A password for your Authelia account, for users_database.yml.
$A crypto hash generate argon2 --password 'your password'Then validate the config before starting anything — Authelia refuses to boot on an unknown key rather than ignoring it, and says so only deep in its log:
podman run --rm -v ./deploy/authelia:/config:z docker.io/authelia/authelia:4 \
authelia validate-config --config /config/configuration.yml
podman compose -f deploy/docker-compose.yml up --build -dIf Authelia sits behind your own CA, mount it and point SSL_CERT_FILE at it —
Go-Notes trusts the host store as well as the bundled public roots. Without
that you get invalid peer certificate: UnknownIssuer at startup.
For rootless Podman with systemd, see
deploy/podman/README.md. Every configuration key
is documented in
deploy/config/config.example.toml;
each can also be set as GO_NOTES__SECTION__KEY in the environment.
Browser ── HTTPS ──> Caddy ──> go-notes (axum, :8080) ──> Postgres (derived index)
│ │
│ ├──> /data/notes/<user>/**.md (source of truth)
└─ Authelia │
(OIDC) └──> embeddings (BGE-small-en-v1.5, optional)
One binary serves the API and the WebAssembly frontend. The server is axum and sqlx; the frontend is Leptos compiled to WASM, including the graph physics. The only JavaScript is a small bridge around Milkdown, because a rich-text editor with faithful markdown round-tripping does not exist in the Rust ecosystem.
The markdown files are the source of truth; Postgres is a cache. Every table
except users and sessions is derived from the files, which is why every write
touches the filesystem first and the database second, and why losing the database
is an inconvenience rather than a disaster. You can check the claim rather than
trusting it:
podman exec go-notes-postgres psql -U go_notes -c \
'TRUNCATE notes, folders, tags, attachments CASCADE;'
podman restart go-notes # everything comes backDeleting a note never unlinks it: files move to .trash/<timestamp>/ with their
original path underneath, so a mis-click is undone with mv.
go-notes user add josh # add, or change a password. Also: list, remove
go-notes check # report where the index disagrees with the filesystem
go-notes reindex # rebuild the index from the filesystem
go-notes embed # embed new passages and recompute suggested links
go-notes embed --all # start the vectors again, after changing model
go-notes healthcheckPasswords are never taken as command-line arguments — that puts them in your
shell history and in ps. Use the prompt or --password-env VARNAME.
Tuning suggested links. embeddings.min_score decides how alike two
passages must be, and the right value depends on your notes. BGE's scores sit in
a narrow high band — measured against passages embedded exactly as
embed_missing sends them (heading and body together), unrelated topics still
scored up to 0.61 and genuine matches started at 0.74 — so the shipped 0.70 is
a measured starting point for the default BGE-small-en-v1.5, not a guess, but it
is still a starting point: a different model, or a vault of much shorter or
longer notes than this was measured on, shifts the band. Look at what you got,
adjust, and re-run go-notes embed; vectors are cached by content, so
recomputing after a change costs nothing at the model.
podman exec go-notes-postgres psql -U go_notes -c "
SELECT round(score::numeric, 3) AS score, s.rel_path, t.rel_path
FROM semantic_links l
JOIN notes s ON s.id = l.source_note_id
JOIN notes t ON t.id = l.target_note_id
ORDER BY score DESC LIMIT 40;"A stronger model, if you have the hardware. BGE-small matches on vocabulary
more than meaning. Two notes sharing words but not a topic — a note about the
Mermaid diagramming tool and an unrelated note that happens to mention mermaids,
architecture and a diagram type by name — scored higher against each other
(0.792) than the real relationship in the same vault (0.742). Qwen/Qwen3-Embedding-0.6B
told them apart cleanly: the real relationship scored 0.669, every false match
involving the unrelated note scored 0.514 or lower.
That quality costs real resources, and comes with a sharp edge worth knowing
about before you hit it: Qwen3-Embedding supports sequences up to 32,768
tokens, against BGE's fixed 512-token limit, and dense attention cost grows
with the square of sequence length. text-embeddings-inference's default
warmup exercises the full --max-batch-tokens budget (16384 by default) —
which OOM-killed a 14GB host outright before serving a single request. Capping
it well below default is not optional:
# deploy/.env
EMBEDDINGS_MODEL=Qwen/Qwen3-Embedding-0.6Band in the compose file's embeddings service, add the flag that keeps warmup
from repeating the OOM above:
command: ["--model-id", "${EMBEDDINGS_MODEL:-BAAI/bge-small-en-v1.5}", "--auto-truncate", "--max-batch-tokens", "2048"]Two more things change together with the model, both easy to miss:
batch_size(config.toml, orGO_NOTES__EMBEDDINGS__BATCH_SIZE) — the default32passages per request no longer fits inside a 2048-token budget. Drop it to8.min_score— Qwen's scores sit on a completely different scale than BGE's, not just a shifted version of the same one (1024 dimensions against 384, a different training objective entirely). Carrying over0.70would filter out the real 0.669 match above. Re-measure using the query in the previous section after switching;0.55–0.60is a starting point based on the gap measured here, not a settled number.
Budget roughly 8x the idle memory of bge-small (~2.5GB against ~340MB) and a warmup in the tens of seconds rather than instant. Worth it on a host with RAM to spare and notes where lexical overlap without topical overlap is a real risk (a personal wiki that reuses the same handful of words for different things); not worth it as a default on the NUC-class hardware this project is sized for, which is why bge-small stays the shipped choice.
- The editor normalises formatting. Round-tripping through a syntax tree re-pads tables and similar. Meaning is preserved exactly and the result is a fixed point, but it is not always byte-identical. A note you only read is never rewritten, so a vault under git will not fill with diffs you did not make.
[[label::name]]is read as a typed link. So[[std::vector]]is taken as the relationstdpointing atvector. Anything a label cannot contain — a dot, a slash, a leading digit — is safe, as is anything inside code. Write[[./std::vector]]to force a literal target.- Offline mode only knows the notes you have opened on that device, so
offline search and backlinks cover those rather than the whole vault. The graph
needs the server and says so. Reloading while disconnected needs the service
worker, which browsers only allow over HTTPS or on
localhost. - Frontmatter aliases are not resolved. Links resolve by path and filename.
- No note history, and login throttling resets on restart.
git initin a vault covers the first; Authelia's persistentregulationcovers the second.
- Paths pass two gates: syntactic rules shared with the frontend, and a filesystem gate that refuses to traverse a symlink out of a vault.
- Sessions are opaque tokens stored only as their SHA-256, so a database dump
yields no working cookies. Writes need a same-origin
Originheader on top of aSameSite=Laxcookie, and carry anIf-Matchhash so a concurrent edit becomes a conflict you resolve rather than silent data loss. - Uploads are typed by sniffing their contents, never the client's claim. Only what a browser renders safely is served inline; everything else, including SVG, downloads.
- The page fetches nothing from another origin — no CDN, no webfont, no
analytics — and
crates/server/tests/airgap.rsfails the build if that stops being true. The CSP allows'self'and carries a hash of the WASM loader rather than'unsafe-inline'. - Where your notes go. Nothing leaves the machine by default: the example
deployments run the embeddings model themselves, on an internal network with no
published ports. Point
embeddings.api_baseat a hosted API and the text of your notes is sent there, a passage at a time — the server says which of the two it is in its log at startup. The browser never talks to the model either way. - Offline mode caches notes in the browser's IndexedDB. That is real data at rest on the device: it is cleared on sign-out and on a different user signing in, and removable on demand from the command palette.
See CLAUDE.md for the build order, the test commands and the architecture notes worth reading before changing anything.
cargo test # server and shared crates
cargo test -p go-notes-ui # frontend logic
cd editor && npm install && npm run build # the editor bridge
cd crates/ui && trunk build # the WASM frontendMIT.