> Signed self-updates for a desktop app, hosted on object storage — from The Handover, the-handover.org/docs/desktop-app-signed-self-updates > Authors: Leon Mallett (captivated.online) with Claude Code · Last confirmed working: 2026-08-08 > © Captivated Ltd — free to use in your own work, not to redistribute as a collection. the-handover.org/licence Giving a desktop application signed self-updates, hosted on object storage behind a custom domain. Written against Tauri v2 and Cloudflare R2; the shape applies to any updater that verifies a detached signature against a public key baked into the app. ## The model, in three steps 1. The app is built with updater artifacts enabled, which emits an **update payload** per platform plus a **detached signature** for each, signed with your **updater private key**. 2. You upload the payloads and a **version manifest** to a public URL. 3. The running app fetches the manifest and, if the advertised version is newer than the installed one, downloads the payload, **verifies the signature against the public key compiled into the app**, installs it, and relaunches. **Two independent signings — do not conflate them.** *Code signing* (Developer ID on macOS, EV on Windows) makes the operating system trust the app. *Update signing* (the keypair in this document) makes the app trust an update. A release build needs both. ## Key management **One updater keypair per application.** A leaked key then compromises one app rather than a portfolio. The public key is committed in the app's configuration; the private key and its password are secrets. Generate the production key **in a human terminal**, not through an assistant, so the private key never enters a transcript: ```bash npx tauri signer generate -w ~/.tauri/-updater.key ``` Set a password, and **back up both the file and the password**. Losing them is unrecoverable: you can never sign another update, and every installed copy must be replaced manually by its user. That is the single worst failure mode here, and it is entirely preventable. **Use a throwaway key to prove the mechanism.** Validate the whole flow with a disposable key first, then swap in the real one. But **delete every dev-key-signed object from the bucket before shipping** — a manifest signed with a key that does not match the app's compiled-in public key fails verification and breaks updates for everyone. ## App-side wiring ```toml # Cargo.toml tauri-plugin-updater = "2" tauri-plugin-process = "2" # for relaunch() after install ``` ```jsonc // tauri.conf.json "plugins": { "updater": { "pubkey": "", "endpoints": ["https://releases..example/latest.json"], "windows": { "installMode": "passive" } } }, "bundle": { "createUpdaterArtifacts": true } ``` Capabilities need `updater:default` and `process:allow-restart`. The frontend flow is `check()` → `downloadAndInstall()` → `relaunch()`. **On checking automatically.** If your application promises to be quiet on the network, an updater is a network call the user did not ask for. Default to a manual "Check for updates" action plus an **off-by-default** "check on launch" setting, rather than silently polling. This is a positioning decision as much as a technical one, and it is easier to make correctly at the start. ## The version manifest ```json { "version": "0.1.1", "notes": "What's new…", "pub_date": "2026-07-13T18:13:37Z", "platforms": { "darwin-aarch64": { "signature": "", "url": "https://releases..example/0.1.1/.app.tar.gz" } } } ``` - `signature` is the **literal contents** of the `.sig` file, not a path to it. Embed it with `jq --arg sig "$(cat ….sig)"` rather than by hand. - An update triggers only when the manifest version is **greater than** the installed one. Equal means "up to date", and the signature is not checked in that case — so a broken signature can hide until the first real upgrade. - Endpoints must be HTTPS. ## Hosting One bucket per app. Attaching a public custom domain needs the **zone ID**, which is not the account ID and lives in a different part of the dashboard: ```bash npx wrangler r2 bucket create -releases npx wrangler r2 bucket domain add -releases \ --domain releases..example --zone-id --min-tls 1.2 -y ``` The platform provisions DNS and TLS within about a minute. A `404` served by the platform means it is working and the bucket is empty. Serving updates from a custom domain rather than a platform subdomain matters for the same reason it matters for any user-facing endpoint: free platform subdomains get blocked as a category by security filters, and an update endpoint that fails for a subset of users fails invisibly. ## Cutting a release ```bash # 1. Bump the version in the app config. # 2. Build with BOTH signings available in the environment. export APPLE_SIGNING_IDENTITY="Developer ID Application: ()" export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/-updater.key)" export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" npx tauri build # 3. Build the manifest, embedding the signature safely. SIG="$(cat target/release/bundle/macos/.app.tar.gz.sig)" jq -n --arg sig "$SIG" --arg pd "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '{ version:"0.1.1", notes:"…", pub_date:$pd, platforms:{ "darwin-aarch64":{ signature:$sig, url:"https://releases..example/0.1.1/.app.tar.gz" } } }' > latest.json # 4. Upload. --remote is NOT optional. npx wrangler r2 object put -releases/0.1.1/.app.tar.gz \ --file target/release/bundle/macos/.app.tar.gz \ --content-type application/gzip --remote npx wrangler r2 object put -releases/latest.json \ --file latest.json --content-type application/json --remote ``` **Without `--remote`, the upload targets a local simulation.** The command succeeds, prints nothing alarming, and changes nothing in the real bucket. This is the single easiest way to spend twenty minutes wondering why installed apps cannot see a release that is definitely there. The updater payload on macOS is the `.app.tar.gz`, not the `.dmg`. The DMG is for first-time downloads and is the thing you notarise. ## Proving it end to end Do this once per application, before you need it to work. 1. Build version *n* and **preserve the built app somewhere writable** — a later build overwrites the target directory. 2. Bump to *n+1*, build, and upload its payload plus a manifest advertising it. 3. Open the preserved version *n* app, check for updates, install, let it relaunch. 4. Verify the result rather than trusting the UI: ```bash /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" .app/Contents/Info.plist codesign --verify --deep --strict .app ``` The version must have changed *and* the signature must still be valid. An update that installs but breaks the signature is worse than one that fails to install. ## Gotcha quick-reference | Symptom | Cause / fix | | --- | --- | | Object storage commands fail with an "enable" error | The service is not enabled on the account. One-time dashboard step; needs a payment method even on the free tier. | | `bucket domain add` complains about a missing zone ID | It is required. The zone ID is per-domain and is not the account ID. | | Upload appears to work but the object is not there | Missing `--remote`. The command targeted a local simulation. | | App never finds an update | Manifest version must be strictly greater than installed; endpoint must be HTTPS; JSON must be valid. | | Update downloads then fails verification | The payload was signed with a key that does not match the app's compiled-in public key. Often a stale dev-key manifest left in the bucket. | | `signature` field rejected | It must be the contents of the `.sig` file, not a path. | | macOS: updated app will not launch | The update payload must itself be code-signed. Notarise the first-download DMG. | | Windows shows an installer window during update | Set the updater's install mode to passive or quiet. | | Lost the private key or its password | Unrecoverable. No further updates can ever be signed; users must reinstall manually. Back it up. | ## For CI The key contents and password become encrypted CI secrets, and uploads use a scoped API token with write access to the release bucket rather than interactive authentication. Scope the token to that one bucket: it is the credential most likely to end up in a log.