~104 MB on top of ~85 MB, almost all of it the AppImage. That is what the product being complete costs (rule 23): a self-hoster gets a working app for their machine from the server holding their notes, with no account on a forge that is private. The AppImage is not optional within that — it is the only bundle that can replace itself in place, so a server without one cannot serve in-app updates to anybody. `packaging/fetch-clients.sh` replaces the inline fetch and writes the fixed names and sidecars `client_dist.py` reads. It never fails: a platform with nothing published means the server advertises nothing for it and the UI hides that download, and eight fetches must not become eight ways to redden a green lane. THE VERSION IS FETCHED, NOT DERIVED, and this is the part that would have been wrong the easy way. The obvious shortcut is `version.sh display desktop` in the image job — it has the checkout. But this commit may not be the commit the channel is serving: a push touching only `src/` does not rebuild the desktop, so the channel still holds an older build and a locally-derived version would describe those bytes with this commit's number. `client_dist.py`'s size check could not catch it, because size IS measured from the real file — it would sail through and lie about the version alone. So `write-manifest.sh` now publishes `thoughtsync-desktop.json` beside `latest.json`, from the same two values in the same breath, and only size/sha256 are measured at bake time. Which needed the prune's keep-list, or the sidecar would have been uploaded and deleted again in the same run — a fixed name is self-limiting, which is exactly why that list exists. `version_code` is NOT uniformly an integer, and coercing it was a leftover from the days when Android was the only platform. Android's must stay a JSON number: `ClientRelease` in core declares it `i64` and a string fails to deserialize on every phone in the field. The desktop's is Tauri's semver key `1.0.<minutes>` — the value its updater actually compares — and `int()` would have rejected every desktop sidecar CI writes. The table now says which is which, and tests pin both directions. Also retires the comment above the fetch step, which claimed the APK came from "always the rolling dev release" and mentioned `:<version>` images. M314 step 3 made the channel conditional in the code directly below it, and step 6 removed version-shaped image tags entirely. Verified against the live dev channel before pushing: the Android half resolves and exits 0, the desktop half degrades with a warning because the sidecar does not exist yet, and all five constructed bundle filenames return 200.
ThoughtSync
Self-hosted personal thought-capture web app in the FabledSword family — a
Google-Keep-style masonry post-it board for capturing disparate thoughts in
under a second, designed to grow into a lightweight second brain (labels, search,
[[wiki-links]], graph).
- Stack: Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI.
- Auth: native email + password (signed-cookie sessions). Multi-user with the family owner + direct-share + group-share ACL.
- Web-first. An Android companion is a later, conditional milestone.
Layout
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db)
alembic/ async migrations (schema built via `alembic upgrade head`)
tests/ DB-free unit tests (pytest)
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
Dockerfile 2-stage build (Vue -> python:3.12-slim runtime)
docker-compose.yml local app + Postgres stack
Development
Backend (needs a Postgres reachable at THOUGHTSYNC_DATABASE_URL):
pip install -e ".[dev]"
alembic upgrade head
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000
Frontend (proxies /api to :5000):
cd frontend
npm install
npm run dev # http://localhost:5173
Or run it in Docker. Hot-reload dev stack (edit code, changes reload live) — Postgres + backend + Vite dev server, app at http://localhost:5173:
docker compose -f docker-compose.dev.yml up
Or the full production image (SPA baked in) at http://localhost:5000:
docker compose up --build
Deploy (self-host)
A complete two-container stack (app + Postgres) you can copy and run. Save it as
docker-compose.yml, change the two CHANGE_ME passwords (they must match), then
docker compose up -d:
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: thoughtsync
POSTGRES_PASSWORD: CHANGE_ME # change this
POSTGRES_DB: thoughtsync
volumes:
- thoughtsync-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
interval: 5s
timeout: 5s
retries: 10
app:
image: git.fabledsword.com/bvandeusen/thoughtsync:latest # :dev for the current dev build
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync
volumes:
- thoughtsync-data:/var/thoughtsync # uploaded images; omit if you don't use attachments
ports:
- "5000:5000"
volumes:
thoughtsync-db:
thoughtsync-data:
Then open http://<host>:5000 and register — the first account becomes the admin.
- Only
THOUGHTSYNC_DATABASE_URLis required.THOUGHTSYNC_SECRET_KEYis optional; if unset, a signing key is generated and persisted in the database (sessions survive restarts). - Uploaded images live under the
thoughtsync-datavolume at/var/thoughtsync. - The app waits for the database and runs migrations (
alembic upgrade head) automatically on start. - Image tags:
:latest(stable, built frommain) ·:dev(latestdevbuild) ·:<git-sha>onmainonly (immutable, the rollback unit). There are no version-shaped tags: nothing pins one, and the build reports its own version at/api/configand/health. - Putting it on the public internet: there are four things to do first — close
registration, terminate TLS and forward
X-Forwarded-Proto, stop publishing the app port, and back up the attachment volume as well as the database. See docs/public-hosting.md, which also lists what the app hardens on its own and what it deliberately doesn't. - Install as an app (PWA): ThoughtSync is installable ("Add to Home Screen" / the
browser's install button) for an app-like window. Browsers only offer install over a
secure context, so put the app behind a reverse proxy terminating HTTPS (or reach
it via
localhost) — plainhttp://<host>:5000won't show the install prompt.
Milestones
- M0 — Foundation & Identity ✅: Quart+Vue+Postgres skeleton, native auth, sharing-ACL spine, Fabled-Git CI.
- M1 — Capture Core ✅: note model + masonry board (quick-add, colors, pin, edit-in-place, archive, trash+restore).
- M1.5 — Roles & DB-backed Settings ✅: first user is admin, admin Settings UI,
DATABASE_URLis the only required env. - M2 — Organize: labels/tags, search, checklists, attachments.
- M3 — Second-brain seeds:
[[wiki-links]], backlinks, graph view, reminders. - M4 — Launch hardening: responsive/PWA, settings UI, polish.
Work happens on dev; main is protected (PR-only).