Signed self-updates for a desktop app, hosted on object storage
Hard-WonWiring an updater keypair, a version manifest and a public bucket, and the flags that make uploads silently do nothing.
- Authors
- Leon Mallett, Founder of Captivated Ltd with Claude Code
- Status
- Last confirmed working 8 August 2026 on Tauri 2.x, wrangler 4.x
- Written
- 13 July 2026
- Licence
- Handover-1.0
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
- 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.
- You upload the payloads and a version manifest to a public URL.
- 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:
npx tauri signer generate -w ~/.tauri/<app>-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
# Cargo.toml
tauri-plugin-updater = "2"
tauri-plugin-process = "2" # for relaunch() after install
// tauri.conf.json
"plugins": {
"updater": {
"pubkey": "<contents of your .key.pub>",
"endpoints": ["https://releases.<app>.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
{
"version": "0.1.1",
"notes": "What's new…",
"pub_date": "2026-07-13T18:13:37Z",
"platforms": {
"darwin-aarch64": {
"signature": "<contents of the .sig file>",
"url": "https://releases.<app>.example/0.1.1/<App>.app.tar.gz"
}
}
}
signatureis the literal contents of the.sigfile, not a path to it. Embed it withjq --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:
npx wrangler r2 bucket create <app>-releases
npx wrangler r2 bucket domain add <app>-releases \
--domain releases.<app>.example --zone-id <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
# 1. Bump the version in the app config.
# 2. Build with BOTH signings available in the environment.
export APPLE_SIGNING_IDENTITY="Developer ID Application: <ORG NAME> (<TEAM_ID>)"
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/<app>-updater.key)"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="<the key password>"
npx tauri build
# 3. Build the manifest, embedding the signature safely.
SIG="$(cat target/release/bundle/macos/<App>.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.<app>.example/0.1.1/<App>.app.tar.gz" } } }' > latest.json
# 4. Upload. --remote is NOT optional.
npx wrangler r2 object put <app>-releases/0.1.1/<App>.app.tar.gz \
--file target/release/bundle/macos/<App>.app.tar.gz \
--content-type application/gzip --remote
npx wrangler r2 object put <app>-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.
- Build version n and preserve the built app somewhere writable — a later build overwrites the target directory.
- Bump to n+1, build, and upload its payload plus a manifest advertising it.
- Open the preserved version n app, check for updates, install, let it relaunch.
- Verify the result rather than trusting the UI:
/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" <App>.app/Contents/Info.plist
codesign --verify --deep --strict <App>.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.