Self-hosted notes sync — Go + SQLite (zero infrastructure) + Flutter client. Cross-platform: Windows · Linux · macOS · Android · iOS. Version control, conflict resolution, and tombstone cleanup. Suitable for personal and small team deployments.
Three design choices keep Notee lean and simple:
- Single Go binary — The server compiles to one file. No runtime, no interpreter, millisecond cold start
- Embedded SQLite — No database process, no connection pool tuning, your data is one
.dbfile - Zero interpreted languages — No JS, no Electron, no Node, no WebView, no HTML. Pure compiled code
Core design:
- File-level version control —
fileVersion+ MD5 integrity check - Incremental sync with version vectors — pulls only what changed since your last sync
- Automatic tombstone cleanup — deletes remote records older than
purgedVersion, preventing deleted records from accumulating indefinitely - Optimistic concurrency with TOCTOU protection — baseVersion check both at HTTP handler AND inside DB transaction
- Per-note encryption — optional AES-256, zero key storage — lost password means lost data
| Artifact | Size | Notes |
|---|---|---|
| Server binary (Linux) | ~9.4 MB | Single Go binary |
| Docker container memory | ~12 MB | |
| Windows client | ~15 MB | Release + obfuscation + AppBundle |
| Linux client | ~13 MB | |
| macOS client (silicon) | ~26 MB | |
| Android APK (arm64-v8a) | ~25 MB | Release + obfuscation + NDK llvm-strip + arm64 only |
| Feature | Detail |
|---|---|
| Storage | Single SQLite file, per-user tables |
| Auth | Random token (32 bytes hex), 30-day TTL, in-memory cache |
| Multi-device | Max N active tokens per account (default 3), oldest kicked when exceeded |
| Token expiry | 401 + X-Auth-Reason header distinguishes "expired" vs "invalid", client auto-relogin |
| Sync Protocol | Version vector (version + purgedVersion), incremental pull, full pull on purge |
| Conflict | baseVersion check, HTTP handler pre-check + DB transaction re-check |
| Resource Protection | MaxBytesReader (100MB), UUID validation against path traversal |
| Admin UI | Built-in HTML, session cookie + SameSite=Strict — http://host:7000/admin |
| HTTPS | Server speaks plain HTTP. Add Nginx / Caddy reverse proxy if you need HTTPS |
| Feature | Detail |
|---|---|
| Platform | Windows, macOS, Linux, Android, iOS (one codebase), responsive layout |
| Editor | AppFlowy Editor (custom fork) — block-based rich text: paragraphs, headings, lists, code blocks (with language detection), todos, links, images |
| Markdown Import / Export | Batch import or export .md / .txt — headings, lists, links, code fences (language-aware) convert to/from native blocks |
| Full-text Search | SQLite FTS5 — per-character CJK indexing, bm25 relevance ranking, snippet keyword highlights, local search history |
| Outline | Editor outline side panel — built from heading levels, tap to jump, highlights current position as cursor/scroll moves |
| Offline-first | Full local Drift (SQLite) cache — read/write without server, sync on reconnect |
| Tags | Per-record tags, virtual tag folders, filter by tag, tag editor dialog, auto-sync with tag edits |
| Reminders | Tasks with repeat modes (once / weekly / monthly / yearly), lunar calendar support, calendar view, today badge count |
| Recycle Bin | Soft delete → restore → permanently purge; capture into bin on delete, restore keeps uuid |
| Conflict UI | Auto-detect, diff view, manual resolve (keep local / keep remote) |
| Encryption | Per-note AES-256 encryption — optional, zero key storage, lost password means lost data |
| Share | Generate an expiring public link for a note (markdown snapshot). Desktop copies the link; mobile uses the system share sheet. Links are AES-GCM tokenized and keyed by the owner's password hash — changing the password revokes all of that user's links |
| i18n | Chinese / English |
Server:
cd server_go/src
go mod download
go run .Client:
cd client_flutter
flutter pub get
flutter run[NOTE]
NOTE_PORT = 10001
NOTE_DATA_DIR = D://test/
NOTE_VERSION_DIR = D://test/version
NOTE_ADMIN_USER = admin
NOTE_ADMIN_PASS = admin
NOTE_MAX_DEVICES = 5
NOTE_MAX_FILE_SIZE = 100Parameters:
NOTE_ADMIN_USER/NOTE_ADMIN_PASS: Admin UI login credentialsNOTE_PORT: HTTP listen port (default 10001)NOTE_DATA_DIR: Data directory for SQLite and uploaded filesNOTE_VERSION_DIR: Client update directory holding platform packages andversions.json(default./version)NOTE_MAX_DEVICES: Max active tokens per account, oldest kicked when exceededNOTE_MAX_FILE_SIZE: Max file upload size per record, in MB (default 100)- Env vars with the same names take precedence over config.ini when
NOTE_ADMIN_USERis set
version: "3"
services:
server:
image: notee:v1.0.0
user: "0:0"
ports:
- "7000:10001"
environment:
- NOTE_PORT=10001
- NOTE_DATA_DIR=/app/data
- NOTE_VERSION_DIR=/app/version
- NOTE_ADMIN_USER=admin
- NOTE_ADMIN_PASS=admin
- NOTE_MAX_DEVICES=5
- NOTE_MAX_FILE_SIZE=100
volumes:
- ./notee:/app/data
- ./version:/app/version
restart: always- Start the server, then open
http://host:port/adminin a browser - Login with the admin credentials configured above
- Create a user account (username + password) in the Admin panel
- Open the Notee client → Settings → enter the server URL (e.g.
http://your-server:10001) and the username/password you just created - Login and start syncing notes across all your devices
Clients check for and download updates from Settings → Version. Update files live in the directory pointed to by NOTE_VERSION_DIR (./version volume in Docker).
GET /upgrade/check?platform=android— query the latest version for a platform (no auth). Returns the full download URL.GET /upgrade/download/{version}/{file}— download an install package (no auth, static file serve).
platform values: windows, linux, macos, android, ios.
Each release goes into its own subdirectory. The server determines the latest version by reading the root version.ini, then looks inside {version}/ for platform info.
version/
├── version.ini # latest=1.1.2
├── 1.1.2/
│ ├── version.json # per-platform md5 + filename
│ ├── changelog.txt # plain-text release notes
│ ├── notee-client-windows-x64.zip
│ ├── notee-client-macos-silicon.zip
│ ├── notee-client-linux-x64.tar.gz
│ └── notee-android-arm64-v8a.apk
└── 1.1.1/
└── ...
version.ini— single linelatest=X.Y.Z, server reads this first.{version}/version.json— per-platform{md5, file}entries, plus top-levelupdatedAt.{version}/changelog.txt— optional plain-text release notes, returned verbatim in the check response.- Package filenames inside a version directory do not carry the version number — the version is determined by the directory name.
- macOS platform key is
macos(notdarwin); server handles it the same as the others. - iOS is accepted as a platform key but no macOS/iOS package is produced by the build scripts.
version.ini — one line:
latest=1.1.2version.json inside a version subdirectory:
{
"updatedAt": "2026-10-12T21:49:26Z",
"windows": {
"md5": "0AB9CC48BB1777B9657F245D67ECA0AF",
"file": "notee-client-windows-x64.zip"
},
"linux": {
"md5": "476123BE55F4B5C9E2B8E47FED54A6B5",
"file": "notee-client-linux-x64.tar.gz"
},
"android": {
"md5": "7CFD9EF9F65F301AA20DED96EDD72BE3",
"file": "notee-android-arm64-v8a.apk"
},
"macos": {
"md5": "EC4492A621F29E00CA04138F3CC1E4FE",
"file": "notee-client-macos-silicon.zip"
}
}Notes:
md5: uppercase MD5 of the install package, returned to the client for integrity checks.file: filename inside the version subdirectory; the server builds the download URL as/upgrade/download/{version}/{file}.updatedAt: optional metadata field, the server doesn't consume it.
Notes can be shared as expiring public links. The client converts the current note to Markdown, uploads the snapshot to the server, and hands the returned link to the system share sheet (desktop copies it to the clipboard).
Shared files live under {recordsFolder}/user_{id}/share/, named:
{uuid}.{random16}.{unixTime}.{lifeSeconds}
uuid— record id, so deleting a record cleans up all its share filesrandom16— 8 random bytes (hex), makes repeated shares of the same note produce unguessable distinct linksunixTime+lifeSeconds— encode the expiry; expired files are swept lazily on each new share by the same user
http://host:port/note/d/{userId}/{title}.md?token={base64}
{userId}— identifies the owner so the server picks the correct decryption key{title}— purely cosmetic (the note title, sanitized for URLs); the real filename is recovered from the token{token}—Base64URL( AES-256-GCM(internalFilename) ), keyed bySHA256(owner's stored password hash)
The path segment is only for display; the server decrypts the token to get the on-disk filename, validates expiry, and serves the file. Changing a user's password invalidates all of their share links.
- Default (no query param): serves an HTML preview page with the note title and a "Download .md" button
?raw=1: returns the original Markdown file as an attachment (Content-Disposition: attachment)
Sharing an encrypted note first prompts for the decrypt password; the decrypted plaintext is converted to Markdown and shared (the ciphertext itself is never shared).
Auth-required APIs need headers: X-User-Id + X-Token.
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /user/register |
Register user | No |
| POST | /user/login |
Login, get token | No |
| POST | /record/update |
Create or update record (baseVersion for conflict) | Yes |
| POST | /record/updates |
Batch upsert file-less records (metadata only, version for conflict) | Yes |
| POST | /record/deletes |
Batch delete records (version for conflict detection) | Yes |
| GET | /record/list |
Query records (incremental pull by version) | Yes |
| GET | /record/download |
Download record file | Yes |
| POST | /record/clear |
Clear all records for current user | Yes |
| POST | /note/upload |
Upload a markdown snapshot, return an expiring public download link (?uuid=, form: file, life seconds, title) |
Yes |
| GET | /note/d/{userId}/{title}.md?token=... |
Download/view a shared note (no auth). ?raw=1 returns the original .md as an attachment |
No |
| GET | /api_stat/list |
Query API call statistics | Yes |
| GET | /upgrade/check |
Check for client updates (platform param) |
No |
| GET | /upgrade/download/{version}/{file} |
Download install package (static file) | No |
| POST | /admin/login |
Admin login | No |
| GET | /admin |
Admin UI | No (session cookie) |
PolyForm Noncommercial License 1.0.0 — free for personal and noncommercial use; commercial use requires prior written consent. See https://polyformproject.org/licenses/noncommercial/1.0.0.