Open-source project
schollz/cowyo avatar
schollz/cowyo

cowyo: a self-hosted scratchpad with real-time editing and browser-side encryption

A feature-rich wiki webserver for minimalists :cow: :speech_balloon:

980 stars67 forksGoLicense varies

At a glance

What is it?
cowyo is a Go wiki server for shared scratchpads: pages save as you type, other people editing the same page see your caret, and permanent private pages encrypt in the browser with the key kept in the URL fragment. The plaintext of a private page never reaches the server, which is also its sharpest limitation.
Who is it for?
cowyo suits a small team that wants a zero-setup pastebin and wiki on its own hardware, a shared page that several people edit at once, or a private note whose plaintext must never be stored server side.
Can I use it commercially?
Not without permission. GitHub finds no licence file in the repository, and without a licence all rights are reserved by default: you may read the code but not reuse it. Check the README, or ask the authors, before using it.
Is it still maintained?
Yes. The repository last received commits 72 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A scratchpad where every keystroke is already saved

cowyo presents itself as a pastebin for minimalists and a feature-rich wiki webserver. The core interaction is deliberately small. Open a page, type, and it saves automatically. Share the URL and other people edit the same page with you in real time.

That real-time layer is more than a text area with a save button. The README describes each other editor's caret appearing as a dim gray line and disappearing when that person leaves, so the page tells you who else is in it right now. A lock feature prevents editing without hiding the contents, which is a different thing from privacy: the README is explicit that anyone with the URL can still read a locked page.

Naming is handled for you. Visiting the root path creates a page with a memorable alliterative name such as calm-cat, and the browser tab identifies the page as calm-cat | cowyo scratchpad, or calm-cat | cowyo private scratchpad for a permanent private page. That naming is a small decision with a real effect on how people share links: a memorable URL is a URL people say out loud in a meeting.

The web client is a Vite frontend under web/, served by the Go binary, and the repository carries both a .air.toml configuration and a Makefile, so the project is set up for a rebuild-on-change loop rather than a hand-rolled dev server.

Private pages encrypt in the browser and keep the key in the fragment

This is the part of cowyo that separates it from a pastebin with a password field.

When you choose Private scratchpad on the landing page, or use the key icon to irreversibly convert an existing unlocked page, the browser generates a random 256-bit master key and keeps it in the URL fragment:

text
https://cowyo.com/calm-cat#key=<base64url-key>

The fragment is never included in HTTP requests or in WebSocket URLs, so the server does not receive the key. From that master key the browser derives independent content and write-capability keys using page-scoped HKDF-SHA-256, encrypts the complete document with XChaCha20-Poly1305, and sends only the ciphertext plus the write capability. The server stores the ciphertext and the SHA-256 of the write capability, so it never holds the material that would decrypt the page.

Access control follows from that design. The complete private URL grants both read and write. A visitor without the correct key sees read-only ciphertext. Private pages cannot be published, cannot be changed through curl or the page-control API, and cannot use the older password-encrypted-block action. Page locking and self-destruct remain available once the private URL has authenticated the browser.

The critical operational warning is in the README itself: the URL cannot be recovered if lost, and there is no downgrade to an ordinary page. A lost link means lost content, with no recovery path through the operator.

The threat model stops at the browser, and third-party scripts cross it

The README is unusually candid about what this encryption does and does not cover, and that candour is the most useful thing in the documentation.

The boundary: E2EE protects against server-side storage disclosure, but a server capable of replacing the JavaScript delivered to the browser is outside this browser-client threat model. If you control the server, you control the code that decrypts, and an operator with the ability to serve a modified bundle can read any private page after it is opened. That is a property of any client-side encryption scheme, and cowyo states it plainly rather than implying the notes are beyond reach.

The second warning is about your own configuration. Configured AdSense and Umami scripts run on private pages and, like any same-page script, can access the URL fragment and the decrypted editor content. The README's advice is to enable only providers you trust with that access. In practice that means: on an instance where people keep real private notes, leave GOOGLE_ADSENSE, UMAMI_URL and UMAMI_WEBSITE_ID unset.

Third, conversion is not retroactive. A page created as private means the server never received plaintext. Converting an existing page does not: plaintext already transmitted may remain in database history, backups, or logs. The word irreversibly in the README describes the page type changing, not history being scrubbed.

One more asymmetry: the plain-text endpoint exists for convenience, and for private pages curl receives only the E2EE ciphertext envelope. It cannot decrypt, edit, operate, or consume a private self-destruct page, because URL fragments are client-side and never sent in an HTTP request.

Password-encrypted blocks coexist with the older action

Before the permanent private pages existed, cowyo had a different mechanism that is still there. Ordinary pages can encrypt their current text into a COWYO ENCRYPTED BLOCK V1, and the encryption happens entirely on the client, in the browser, for the editor. The password is never sent to the server, and encrypted text cannot be recovered if the password is lost.

What makes this feature different from the E2EE page type is scope. An encrypted block preserves ordinary plaintext around it and does not permanently change the page type, so a page can mix readable notes with a locked-off section. The password fields are hidden by default with an eye button to reveal them, and on mobile the password dialog stays inside the visible area as the on-screen keyboard opens, which is a detail that says the author has used it on a phone.

The interaction between the two features is a trap for implementers. Private pages cannot use the older password-encrypted-block action at all. If you build a workflow that assumes every page can carry an encrypted block, that workflow breaks on private pages.

The underlying principle in both cases is the same: the server is a store for opaque bytes. cowyo never holds a password, a master key, or the plain text of a protected page, which is why the loss of a key or password is unrecoverable by design rather than by accident.

The HTTP surface is plain text, with no token and no SDK

cowyo is usable from a shell without an SDK, JSON wrapping, or an API token. A GET prints the exact stored text, so it pipes into another command or redirects to a file:

sh
curl https://cowyo.com/my-notes
curl https://cowyo.com/my-notes > notes.txt

Creating pages over HTTP works with POST. Posting a file to the root path creates a randomly named page and the response is its shareable URL, while posting to a named path creates or replaces that page, with @- reading the body from stdin:

sh
curl --data-binary @notes.txt https://cowyo.com/
curl --data-binary @notes.txt https://cowyo.com/my-notes
printf '%s\n' 'deploy at 3pm' |
  curl --data-binary @- https://cowyo.com/team-handoff

Two rules govern writes. A named POST replaces the page's complete contents, so a POST is not a patch. And locked pages reject normal command-line writes until they are unlocked in the browser or through the page-control API, which the README also lists among the things an existing page can be subjected to, alongside publishing, locking and encryption.

There is an administrative path as well. The .env.example documents ADMIN_POST_KEY as an optional key whose matching X-Cowyo-Admin-Key POSTs bypass rate, size, and page-lock limits, and which may also publish through X-Cowyo-Published: true. The file suggests generating a long random value with openssl rand -hex 32. That is the mechanism an automated publisher should use rather than trying to unlock pages from a script.

Running cowyo: SQLite by default, PostgreSQL on request

Building and running locally is a single make target:

sh
make serve

That target runs the project through air with the .air.toml configuration, so the Go server and the frontend rebuild on change. Open localhost:8001 afterwards, and use -port to pick a different port.

Storage is SQLite by default, with PostgreSQL available by setting DATABASE_URL as shown in .env.example, in which case SQLITE_PATH is unused. The rest of that file is the operational configuration: SITE_URL sets the authoritative public origin for canonical links, social previews, returned paste URLs, robots.txt and the sitemap, and it must be an origin only, without a path, query string, or fragment. GOOGLE_TAG adds the Google tag to ordinary browser-rendered pages, GOOGLE_ADSENSE adds AdSense to every browser-rendered page including permanent private ones, GOOGLE_ADS_TXT is served verbatim from /ads.txt, and UMAMI_URL together with UMAMI_WEBSITE_ID adds the Umami tracker under the same condition.

The Makefile shows the wider toolchain: a build target that runs the frontend build and then go build with trimpath and stripped flags, a generate target that runs sqlc to produce database code from sqlc.yaml, a migrate target that runs ./cmd/migrate, and a test target that runs the frontend tests and go test.

The Dockerfile is a three stage build: a node:24-alpine stage builds the Vite frontend, a golang:1.26.5-alpine stage compiles the binary with CGO_ENABLED=0, and the final alpine:3.23 image adds a non-root cowyo user, sets the SQLite path and exposes the port:

dockerfile
ENV SQLITE_PATH=/data/cowyo2.sqlite3

USER cowyo

EXPOSE 8001

CMD ["/usr/local/bin/cowyo"]

One oddity worth knowing before you file issues: the Go module is declared as github.com/schollz/cowyo2 while the repository is schollz/cowyo, and the default SQLite file is named cowyo2.sqlite3.

Publishing, self-destruct, and the licence question

Pages are unpublished by default and excluded from the sitemap. Publishing makes a page discoverable to search engines and gives it a unique search description based on its plaintext content, and unpublishing removes the content from search and social-preview metadata without making the URL private. That is a discoverability control, not an access control, and the README says so.

Self-destruct is a separate lifecycle feature with two behaviours. An ordinary self-destructing page is returned one final time on its next browser or curl GET, then deleted. A private self-destructing page withholds ciphertext from unauthenticated GETs and is atomically deleted only after a browser proves it has the write capability, and exactly that authorized client receives the final ciphertext over the WebSocket. The private variant is the interesting one, since it avoids handing the last copy to an unauthenticated requester.

On dependencies, the Go module list is short and readable. It uses gorilla/websocket for the live editing channel, pgx for PostgreSQL and modernc.org/sqlite for SQLite without CGO, golang-migrate for schema changes, microcosm-cc/bluemonday as an HTML sanitizer, godotenv for .env loading, and golang.org/x/crypto. The presence of an HTML sanitizer in the dependency list is a small hint that user-supplied content is sanitized on the way in.

The licence is the open question. The project is distributed under an unknown licence: no LICENSE file appears among the top-level repository entries, and the repository page carries no licence identifier. Everything else about the code is permissively usable in practice, but a deployment inside a company needs the licence settled with the author first, and that question is best raised before you publish sensitive notes on an instance you do not own.

On maintenance, the repository is not archived and the last push was on 2026-08-12. The release history shows a long gap, from v2.12.0 in April 2018 to v3.0.0 in July 2026 and v3.0.1 in August 2026, so the current line is a recent major rewrite rather than a slow accumulation of patches, and the older version numbers tell you nothing about the behaviour of the current code.

Editorial conclusion

cowyo suits a small team that wants a zero-setup pastebin and wiki on its own hardware, a shared page that several people edit at once, or a private note whose plaintext must never be stored server side. It does not suit a multi-user knowledge base with accounts and permissions, since access is by URL, and it does not suit a threat model that includes a hostile server, because the client-side encryption stops at the browser and any script on the page, including the AdSense and Umami tags you configure, can read the decrypted content and the key fragment. Verify first that you leave GOOGLE_ADSENSE, UMAMI_URL and UMAMI_WEBSITE_ID unset on an instance holding real private notes, that your reverse proxy terminates TLS so the fragment is never sent over the wire, and that you know which licence you are shipping, since the repository carries no licence file at the top level and the release history jumps from v2.12.0 in 2018 to v3.0.0 in 2026.

Frequently asked questions

How do I build and run cowyo locally?

Run make serve from the repository root, then open localhost:8001. The serve target runs the project through air with the .air.toml configuration, and you can use -port to choose a different port.

How does cowyo encrypt private pages?

The browser generates a random 256-bit master key and keeps it in the URL fragment, derives page-scoped content and write-capability keys with HKDF-SHA-256, and encrypts the document with XChaCha20-Poly1305. The server stores only the ciphertext and SHA-256 of the write capability, and the fragment is never included in HTTP requests.

Can I recover a cowyo private page if I lose the link?

No. The README states that the complete private URL cannot be recovered if lost and that there is no downgrade to an ordinary page, so the page content is gone with the key.

Can I create a cowyo page from the command line?

Yes. POST a file to the root path with curl --data-binary to create a randomly named page and receive its URL, or POST to a named path to create or replace that page. A named POST replaces the complete contents of the page.

Does cowyo use SQLite or PostgreSQL?

SQLite by default, and PostgreSQL when you set DATABASE_URL, in which case SQLITE_PATH is unused. The Docker image sets SQLITE_PATH to /data/cowyo2.sqlite3 and runs as a non-root user.

Official sources

  1. Issues
  2. Project website
  3. README
  4. Releases
  5. schollz/cowyo on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/schollz-cowyo.svg)](https://hysenlabs.com/projects/schollz-cowyo)

Was this useful?