Tauri Patterns for Production: Self-Updating Tauri 2 Apps with Signed Releases
Video: Self-Updating Tauri 2 Apps with Signed Releases | Updater Plugin Tutorial by CelesteAI
Shipping a desktop app is easy. Shipping the next version of a desktop app — without asking every user to download a new installer — is the part most tutorials skip. You need three things wired up together: an updater plugin that checks for new versions and applies them, a code-signing keypair so users can trust the download came from you, and a manifest hosted somewhere that says “here’s the latest, here’s the URL, here’s the signature.”
Tauri 2 ships an official tauri-plugin-updater. Once it’s installed and configured, an app can call app.updater().check(), get back an Update object if a newer version exists, call download_and_install(), and restart itself into the new build. The plugin verifies a cryptographic signature against a public key embedded in your binary before it ever writes a byte to disk. If the signature doesn’t match, the update is rejected.
This tutorial builds Vergo, a minimal app whose only feature is its version number. We’ll ship v0.1.0, build v0.2.0 that changes the version label, sign the v0.2.0 bundle, host a manifest locally, and watch v0.1.0 update itself to v0.2.0 in real time. The pattern generalizes to any Tauri app — the architecture is identical whether the update payload is 5MB of UI tweaks or 50MB of new features.
What the updater plugin does (and doesn’t) do
The updater plugin is a thin wrapper around four operations:
- Fetch a manifest from one of the URLs you configured. The manifest is JSON describing the latest version available.
- Compare versions. If the manifest’s version is newer than
CARGO_PKG_VERSIONbaked into your build, the plugin returns aSome(Update)to you. If not,None. - Download a signed bundle from the URL in the manifest, into a temp directory.
- Verify the signature in the manifest against the public key in your config, then ask the OS to replace the running app with the downloaded bundle and relaunch.
What it doesn’t do:
- It doesn’t host the manifest or the bundles. That’s on you — S3, GitHub Releases, your own server, anywhere that can serve static files over HTTPS.
- It doesn’t generate the manifest. You write it (or generate it from CI) every time you publish a new version.
- It doesn’t handle rollback. If v0.2.0 ships a bug, you have to push v0.2.1 to recover; there’s no “downgrade” button.
- It doesn’t replace the installer. First-time users still need a
.dmgor.msior.AppImage. The updater takes over from launch #2 onward.
The signing piece is what makes the whole thing safe. Without signing, an attacker who can intercept the manifest URL (DNS hijack, compromised CDN, MITM on user wifi) could swap the bundle for malware and the user’s app would happily replace itself with it. The signature check means the attacker would also need your private key, which lives on your build machine and never touches the network.
Step 1 — Generate the signing keypair
Tauri ships a signer generate subcommand that creates a minisign-format Ed25519 keypair:
pnpm tauri signer generate -w ~/.tauri/vergo.key --password ""
This writes two files:
~/.tauri/vergo.key— the private key, kept on your build machine. Never commit it. The build process uses it to sign every release bundle.~/.tauri/vergo.key.pub— the public key, a base64-encoded blob you embed in your app’s binary viatauri.conf.json.
For production, give the key a real password and store the private key in an encrypted vault (1Password, AWS Secrets Manager, your CI’s secrets store). The empty password is fine for the demo, terrible for shipping.
If you ever lose the private key, you cannot ship updates anymore. Existing v0.1.0 users will reject any v0.2.0 you build with a new keypair because the public key in their binary won’t match. The only recovery is shipping a new installer manually and getting users to reinstall. This is one of the better arguments for putting the key in a corporate secrets manager and not on Daryl’s MacBook.
Step 2 — Configure the updater in tauri.conf.json
The updater config goes under plugins.updater:
"plugins": {
"updater": {
"endpoints": ["http://localhost:8787/latest.json"],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6...",
"dialog": true
}
}
Three keys matter:
endpoints— an array of URLs. The plugin tries them in order until one returns 200 with a parseable JSON manifest. For production this should be HTTPS; localhost over HTTP is for testing only. You can have multiple endpoints for redundancy — primary CDN, fallback to GitHub Releases.pubkey— paste the full contents of~/.tauri/vergo.key.pubhere (including theuntrusted comment:header and newline; the build process treats this as opaque base64). This gets compiled into every binary; users can’t change it without recompiling, which is the point.dialog—truemakes the plugin pop a native OS dialog (“Update v0.2.0 available — Install / Later”).falsemeans you build the UI yourself in your frontend. For most apps,trueis fine.
You also need to enable the updater bundle target. Under bundle.targets, include "updater" (or use "all"), and add "createUpdaterArtifacts": true. The build will then produce a .app.tar.gz (macOS) or .zip (Windows) alongside the regular installer, plus a .sig file containing the bundle’s signature.
Step 3 — Wire the plugin in Rust
The plugin needs three lines in lib.rs:
use tauri_plugin_updater::UpdaterExt;
#[tauri::command]
async fn check_update(app: tauri::AppHandle) -> Result<String, String> {
let updater = app.updater().map_err(|e| e.to_string())?;
match updater.check().await.map_err(|e| e.to_string())? {
Some(update) => {
update
.download_and_install(|_, _| {}, || {})
.await
.map_err(|e| e.to_string())?;
app.restart();
}
None => Ok("up to date".into()),
}
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![check_update])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
UpdaterExt is the trait that adds app.updater() to AppHandle. The check() call hits the configured endpoint and returns Option<Update> — Some if there’s a newer version, None if the local version is current. The two closures passed to download_and_install are progress callbacks ((downloaded_bytes, total) per chunk, then a “done” callback) — empty closures here, but in a real app you’d pipe these to a progress bar in the UI.
app.restart() does what it says: kills the current process and relaunches the binary. Because download_and_install has already swapped the binary on disk at that point, the relaunch picks up the new version. The user sees one app blink and come back as v0.2.0.
The Result<String, String> return type means errors propagate to the frontend as strings — easy to log, easy to display. Tauri’s IPC layer serializes Result automatically.
Step 4 — The capability permission
Tauri 2’s permission system requires you to declare which plugin permissions each window gets. In src-tauri/capabilities/default.json:
{
"windows": ["main"],
"permissions": [
"core:default",
"updater:default"
]
}
updater:default grants the window the right to call check and download_and_install. Without this line, the frontend’s invoke("check_update") call would fail with a permission error even though the Rust command is wired up. This is intentional — capabilities are a defense-in-depth feature, not a bypass.
If you only want one window to be able to trigger updates (say, a “Preferences” window and not the main UI), point windows at just that label. For Vergo, the main window does the check, so it’s the only one that needs the permission.
Step 5 — The frontend
The React side is small:
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
export default function App() {
const [version, setVersion] = useState("");
const [status, setStatus] = useState("");
useEffect(() => {
getVersion().then(setVersion);
}, []);
async function check() {
setStatus("checking…");
try {
const r = await invoke<string>("check_update");
setStatus(r);
} catch (e) {
setStatus(`error: ${e}`);
}
}
return (
<main>
<h1>Vergo</h1>
<div>v{version}</div>
<button onClick={check}>Check for Updates</button>
<div>{status}</div>
</main>
);
}
getVersion() reads version from Cargo.toml (via the runtime app metadata). Showing it in the UI is the only way a user can tell that the update worked — when they click “Install” in the dialog and the app relaunches, the version label flips from v0.1.0 to v0.2.0 and that’s their proof.
The try/catch matters. If the network is down, or the manifest is malformed, or the signature is invalid, the Rust side returns Err(String) and the frontend gets a thrown promise. Without the try, the click handler would log to console only — useless feedback. With it, you can show “error: network unreachable” or “error: signature mismatch” in the UI.
Step 6 — Build v0.1.0
With the keypair generated and the config in place, build the first version:
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/vergo.key)"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
pnpm tauri build --bundles app dmg updater
The two env vars tell the build process where the signing key lives. With them set, every bundle target that supports signing produces both the bundle and a .sig file. The --bundles flag controls which formats get built — app for the .app bundle, dmg for the disk image installer, updater for the .app.tar.gz that the updater plugin downloads.
The build drops everything into src-tauri/target/release/bundle/:
bundle/
├── macos/
│ ├── Vergo.app/ ← v0.1.0 .app bundle
│ ├── Vergo.app.tar.gz ← v0.1.0 updater payload
│ └── Vergo.app.tar.gz.sig ← signature, base64
└── dmg/
└── Vergo_0.1.0_aarch64.dmg ← v0.1.0 installer
For first-time install we use the .dmg (or just drop the .app into /Applications manually). For subsequent updates the plugin downloads the .app.tar.gz, verifies the .sig, untars over the existing app, and restarts.
Step 7 — Build v0.2.0
Bump the version in three places:
src-tauri/Cargo.toml→version = "0.2.0"src-tauri/tauri.conf.json→"version": "0.2.0"package.json→"version": "0.2.0"
Make a visible change. Anything. For Vergo the only thing that changes is the version label rendered by getVersion() — but that’s the entire point of the demo. If you wanted, you could change the headline color or add a feature, but the minimal change is exactly the right scope for “did the update mechanism work or not?”
Rebuild with the same env vars set:
pnpm tauri build --bundles app dmg updater
Now bundle/macos/ contains Vergo.app.tar.gz signed with the same key, plus its .sig. The signature is what the v0.1.0 user’s plugin will verify before installing.
Step 8 — Write the manifest
The plugin expects a manifest with this shape (see the Tauri docs for the full schema; this is the macOS-only minimal version):
{
"version": "0.2.0",
"notes": "Bug fixes and version display improvements.",
"pub_date": "2026-05-17T00:00:00Z",
"platforms": {
"darwin-aarch64": {
"signature": "<contents of Vergo.app.tar.gz.sig>",
"url": "http://localhost:8787/Vergo.app.tar.gz"
}
}
}
The signature field holds the full contents of the .sig file produced by the build — including the untrusted comment: header. The url points at the actual .app.tar.gz bundle. The plugin downloads the bundle, computes its signature, compares against the manifest signature, and only proceeds if they match (and both verify against the embedded public key).
Platform keys are darwin-x86_64, darwin-aarch64, windows-x86_64, etc. You can include multiple platforms in one manifest; the plugin picks the entry that matches the running platform.
Step 9 — Host the manifest
For local testing, python3 -m http.server 8787 from a directory containing latest.json and the v0.2.0 .app.tar.gz is enough. For production, push these to a CDN-backed bucket and set the endpoints array to the HTTPS URL.
A common pattern is to publish to GitHub Releases. GitHub Releases gives you static URLs for each release asset, and you can point endpoints at the release tag’s manifest. Some teams generate the manifest in CI, sign the bundles in a sandboxed signing job with the private key injected from a secret store, and upload everything in one workflow step.
The full update flow, end-to-end
When a v0.1.0 user clicks “Check for Updates”:
- The frontend calls
invoke("check_update"). - The Rust command calls
app.updater().check(). - The plugin GETs
http://localhost:8787/latest.json. - The plugin parses the JSON, compares
"0.2.0"against the embeddedCARGO_PKG_VERSION("0.1.0"), and returnsSome(Update). - The Rust command calls
update.download_and_install(...). - The plugin GETs
http://localhost:8787/Vergo.app.tar.gz. - The plugin verifies the bundle’s signature against the manifest signature and the embedded public key.
- If
dialog: true, the OS shows a native confirmation dialog. - On approval, the plugin untars the new bundle over the existing app.
- The Rust command calls
app.restart(). - The OS relaunches
/Applications/Vergo.app, which is now v0.2.0. - The user sees
v0.2.0in the UI. Confirmed update.
The whole thing takes 3-5 seconds for a small app, 10-30 seconds for a larger one. The UX is intentionally minimal: dialog, click, refresh. Users barely notice.
Security notes worth re-reading
The private key is the perimeter. If an attacker gets your vergo.key file, they can sign arbitrary updates that your users will install without warning. Treat it like an SSH key for your production servers — encrypted at rest, never in version control, never on a shared machine.
Don’t run HTTP in production. The manifest URL is the one piece an attacker can intercept without your private key. HTTPS prevents downgrade attacks (serving an old v0.0.9 manifest to force users to a known-vulnerable version) and tampering (changing the URL the manifest points at). The plugin still verifies signatures, but defense in depth.
Test the update flow before you publish v0.2.0. Install v0.1.0 fresh on a test machine, run the update, confirm the new version takes effect and the app still launches. Most updater bugs are config typos in tauri.conf.json — wrong pubkey, wrong endpoint URL — and they only show up when you actually try to update.
Plan for rollback. Keep the previous version’s bundle on your CDN. If v0.2.0 ships broken, you can publish a v0.2.1 manifest that points at the v0.1.x bundle as the “new” version. This is hacky but works.
When not to use the updater plugin
A few situations where the built-in updater isn’t the right fit:
- App Store / Mac App Store / Microsoft Store distribution. These stores handle updates themselves and will reject apps that ship their own updater. Disable the plugin (or compile it out) for store builds.
- Enterprise environments with MDM. IT departments often manage app versions via MDM and want to prevent users from auto-updating. Give them a config option (or a feature flag) to disable the check.
- Apps shipped via Homebrew Cask or apt or Snap. The package manager owns updates. Don’t fight it.
For everything else — direct distribution, indie apps, internal tools — the plugin is the right answer. It’s three config keys, one Rust command, and one frontend invoke. Once you’ve got it set up, you never have to think about it again.
That’s the full pattern. Generate keys, embed the public key, build signed bundles, host a manifest, call check() from a button. Vergo is 60 lines of code total. The same architecture scales to a 10MB IDE or a 100MB design tool — only the size of the payload changes.
The hardest part of shipping desktop software is the second release. With this in place, the tenth release is the same amount of work as the second: bump the version, run build, push the manifest. Users wake up the next day with the new version already installed.