Stash Notes is a self hosted notes app I built for my own use over a few weekends and then several months of evenings. Single user, end to end encrypted, with image attachments, pinned notes, and a shopping list mode. It is not a product. It does not have a landing page. It runs on a small VPS that costs me a few pounds a month, and I use it every day.
Writing it taught me a number of things about encryption, about state management, and about how much of what I thought were “the easy parts” are in fact where most of the work lives. This is the post I would have wanted before I started.
Why I built it
The honest reason is that I do not really trust any of the existing notes apps with the parts of my notes that actually matter. The good ones are owned by companies whose business model is unclear, and whose privacy posture has shifted over time. The mediocre ones store everything on the vendor’s database and ship the data through whatever telemetry their product team finds interesting.
The serious privacy options exist. Standard Notes is good. Joplin is good. There is a healthy crop of self hosted markdown tools. None of them quite matched the shape I wanted, which is a single user app, with a single rock solid encryption story, with image attachments that work, and with the small quality of life features I actually use. Building it myself was partly a useful exercise and partly a way of forcing myself to understand the threat model in detail.
The single user constraint as a feature
The most useful design decision was choosing to be single user.
Multi user notes apps inherit a long list of hard problems. Sharing models, permissions, conflict resolution between collaborators, account recovery, key escrow for organisations, audit trails, the endless special cases around what happens when one user shares a note with another and then leaves. Each of those is a project in its own right.
Single user dissolves most of them. There is exactly one account. There is exactly one key derived from one password. There is no sharing, so there is no permission system. There is no sync between users, so there is no conflict resolution between competing versions of a note. There is one client at a time in the common case, with a sensible “last write wins” rule for the rare case where a phone and a laptop edit the same note within a few seconds of each other.
The threat model is also far simpler. The threats I care about are: someone steals the VPS, someone subpoenas the hosting provider, my laptop is stolen and someone tries to read the local cache, or someone watches the network and tries to reconstruct what I have written. The threats I do not care about are insider attacks within my own organisation, because there is no organisation, and a malicious collaborator, because there are no collaborators.
That clarity made every subsequent decision easier.
The stack
Node.js for the server. SQLite for storage. A small set of dependencies.
The server side dependency list is short and deliberate. An HTTP framework, a logging library, a few small utilities, a well audited cryptographic library, and that is essentially it. No ORM. The SQL is hand written, parameterised, and small enough to fit in a single file. No background job framework. The two background tasks the app needs are scheduled with setInterval and protected by a single mutex. No frontend framework on the server side. Everything that needs to be reactive lives on the client.
I chose SQLite over Postgres because the data set is small, the access pattern is single writer, and the operational story is dramatically simpler. The entire database is one file. Backups are a copy of that file plus the encrypted blob storage. Restore is the reverse. Postgres is the right answer for a multi user system. For a single user app it is overkill in ways that would have cost me weeks of operational fiddling.
The client is a vanilla web app with a thin routing layer and a small custom state store. I considered React. I considered SolidJS. The notes app is mostly text input and list rendering, both of which the browser does perfectly well without help, and adding a framework would have introduced a build step and a set of opinions about state that I did not want.
The encryption model
What is encrypted: the content of every note, the title of every note, the body of every shopping list, every image attachment, and the text of every search query that gets stored on the server.
What is not encrypted: the timestamps, the per note size in bytes, the existence of notes, and the rough access pattern. All of that metadata is visible to someone with a copy of the server’s database file.
The threat model is plain. An attacker with the server’s storage and no cooperation from the user can see that I have, say, 426 notes, that the largest is 14kb, that I edited a particular note at a particular time, and that I have made a request for a note of a given encrypted identifier. They cannot see what any of those notes contain.
Key derivation runs Argon2id over the user password with parameters tuned to take roughly half a second on the slowest device I might use. The derived key never leaves the client. The server stores only an encrypted vault of per note keys, which the client unwraps locally after authentication.
Every note has its own per note key, generated client side, encrypted under the master vault key, and stored alongside the ciphertext. This is more complex than a single key over the whole vault, and it pays for itself in two places. Image attachments can be stored independently of the note metadata and decrypted only when needed. Future features like sharing a single note out to another person, if I ever needed that, become possible without a complete rebuild.
Actual encryption uses XChaCha20 Poly1305 for content. The choice was driven by the availability of a well audited implementation in the libraries I was already using, and by the fact that the larger nonce size makes it harder to introduce a nonce reuse bug in code I am writing on a Sunday evening.
Image attachments, the part that ate weeks
The fundamental tension is between encrypting the blob and being able to do anything sensible with it.
A naive approach stores each image, encrypted, inside the SQLite database as a BLOB column on the note. This works for small numbers of small images, falls over for anything larger than a few megabytes per note, and turns the database file into a write heavy mess that is unpleasant to back up.
The path I ended up on stores image blobs separately on disk, encrypted with a per blob key, with the key wrapped under the per note key. The note row in the database has a list of blob identifiers and the wrapped keys. The server’s job is then very small. It hands ciphertext blobs back to the client when asked, and accepts new ciphertext blobs from the client. The server does not need to know anything about images, image formats, EXIF metadata, or thumbnails.
That last part matters. The server does no image processing. Thumbnails are generated client side, encrypted, and stored as separate blobs. This added a noticeable amount of client side code, and a small amount of memory pressure on the phone for very large images, in exchange for a clean separation between the part of the system that handles plaintext images briefly in memory on a trusted device, and the part that handles ciphertext blobs on an untrusted server.
The pieces I underestimated were the smaller things. Pasting an image from the clipboard. Handling a HEIC file on iOS, which sometimes needs a transcode step before it will encrypt cleanly. Cleaning up orphaned blobs when a note is deleted. None of those are hard. All of them are fiddly. Each consumed at least one weekend.
Pinning and shopping list mode
These are the features that turned out to be more interesting than I expected, because of how they interact with encryption.
Pinning a note is conceptually trivial. You add a boolean column. The server returns pinned notes first. Done.
Encrypted, it is more annoying. The server cannot read the pinned status from the encrypted note body, so the pinned bit either lives in plaintext on the row, leaking that the user has marked this note as somehow special, or it lives in the encrypted body, in which case the server cannot pre sort the note list and the client has to fetch every encrypted row, decrypt all of them, and sort locally.
I chose the plaintext bit. The leak is small enough that I can live with it, and the user experience of “fetch and decrypt the entire list before showing anything” is bad enough that I could not. This is the sort of decision that you only really make properly when the cost of the alternative is something you have personally typed.
Shopping list mode was similar. A shopping list note has a different rendering, a different keyboard handler, and an “items checked off” state that needs to update quickly when you tap a row. Encryption changes none of that, but it does mean that the “tap to check off” event is a tiny encrypted update, which is fine in absolute terms and slightly annoying in practice because every tap is a network round trip with a key wrap and a key unwrap inside it. A small client side debounce, and a simple “merge several local edits into one server write” rule, made the experience feel responsive again.
What I would do differently
Three things.
I would design the blob storage layer first, before any of the note storage layer, instead of bolting it on once notes were working. The blob layer is the harder design problem, and getting it right early would have saved a substantial amount of refactoring.
I would write the threat model document on the very first weekend, in plain English, even before any code. I did write one, eventually, after I had already made a number of small decisions that turned out to constrain it. Writing it first would have caught at least two design choices I later had to revisit.
I would resist the temptation to add features that “are easy to add now, while the structure is fresh in my head.” Several of the small features I added during a productive evening turned into multi week followups when their interaction with encryption became clear. The structure being fresh in your head is exactly when you are most likely to underestimate the next thing.
Would I recommend anyone else build their own notes app
Honestly, no, unless they want to learn the lessons.
The features in the public alternatives are good, the encryption stories of the better ones are sound, and the maintenance burden of running your own is real. If your goal is “have a private notes app”, picking one of the existing self hosted options will get you 95 per cent of the way there with a tiny fraction of the effort.
If your goal is to genuinely understand how end to end encryption interacts with state, with synchronisation, with attachments, and with the rest of the boring infrastructure that a notes app actually consists of, then there is no substitute for building one. The bits I now understand properly, I understand because I argued with the implementation on a Sunday evening, not because I read a paper.
Stash Notes will probably never leave my own server. It does not need to. It has already done its job.