Building release bundles for macOS, Windows, and Linux
Step 12 of 13. Step 11 gave notes a way out of the database — rendered HTML, saved files, printed PDFs. What the app still is not is something you can hand to another person: npm run tauri dev runs rustnote from your checkout, against your toolchain. This step turns the dev setup into installers. One build command produces the bundles, a signing keypair makes updates possible, and a couple of small config edits are the difference between “it runs on my machine” and “it runs on yours”.
It starts from tauri-ch10, the tag step 11 left behind, and ends at tauri-ch11.
Bump the version, then build
Section titled “Bump the version, then build”create-tauri-app wrote 0.1.0 in three places back in step 2: package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json. The bundler reads the last one, so that is the one that decides the filename — but bump all three so nothing on the page disagrees with anything else. This is the release, so it becomes 1.0.0.
Then the command the whole step is about:
npm run tauri buildVite’s production-build output scrolls past first; the interesting part is what follows it:
Compiling rustnote v1.0.0 (/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri) Finished `release` profile [optimized] target(s) in 1m 47s Built application at: /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/rustnote Bundling rustnote.app (/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app) Bundling rustnote_1.0.0_aarch64.dmg (/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/dmg/rustnote_1.0.0_aarch64.dmg) Running bundle_dmg.sh Finished 2 bundles at: /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app (6.63 MiB) /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/dmg/rustnote_1.0.0_aarch64.dmg (3.14 MiB)Two things about that output will look different on your machine: the path prefix is wherever you cloned the project, and the compile time. The rest is the same — rustnote is this project’s name from step 2, and the two bundles land under src-tauri/target/release/bundle/.
That single command did three jobs: ran npm run build (the beforeBuildCommand from step 2), ran cargo build --release for the Rust core, then handed the binary to the bundler. The first release build is the slow one — the dev profile has its own cache, so release recompiles every crate with lto from the [profile.release] section of step 2’s Cargo.toml. The next build takes a minute.
What lands where
Section titled “What lands where”target/release/rustnote is the bare binary — the same one cargo build --release produces. The two directories under bundle/ are what you distribute:
bundle/macos/rustnote.app— the app bundle, ready to drag into/Applicationsbundle/dmg/rustnote_1.0.0_aarch64.dmg— the disk image users actually download
The filename carries the version from tauri.conf.json and the architecture of the machine that built it. That is the first platform trap, and it is in the name: this dmg runs on Apple Silicon Macs only. An Intel Mac needs a separate build, or a universal one:
npm run tauri build -- --target universal-apple-darwinwhich requires both aarch64-apple-darwin and x86_64-apple-darwin Rust targets installed, and produces one bundle that runs on either.
The dmg is worth opening once, because it is the whole install experience:
hdiutil attach -nobrowse -readonly src-tauri/target/release/bundle/dmg/rustnote_1.0.0_aarch64.dmgls /Volumes/rustnote/dev/disk15 GUID_partition_scheme/dev/disk15s1 Apple_HFS /Volumes/rustnoteApplicationsrustnote.appThe Applications symlink plus the app is the drag-to-install layout Tauri generates. hdiutil detach /Volumes/rustnote when you are done looking.
The signature you have for free
Section titled “The signature you have for free”Ask macOS what the bundle is signed with:
codesign -dv src-tauri/target/release/bundle/macos/rustnote.appExecutable=/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app/Contents/MacOS/rustnoteIdentifier=rustnote-d5535c4054175938Format=app bundle with Mach-O thin (arm64)CodeDirectory v=20400 size=53240 flags=0x20002(adhoc,linker-signed) hashes=1660+0 location=embeddedSignature=adhocInfo.plist=not boundTeamIdentifier=not setSealed Resources=noneInternal requirements=noneRead the flags line: adhoc,linker-signed. Apple Silicon refuses to execute any unsigned binary, so the linker stamps every binary it produces with an ad-hoc signature at build time. That stamp is on the executable only — Sealed Resources=none means the bundle as a whole (its Info.plist, its resources) was never signed, and the rustnote- identifier with a hash suffix is the linker’s, not yours.
Gatekeeper’s assessment tool notices:
spctl -a -vv src-tauri/target/release/bundle/macos/rustnote.appsrc-tauri/target/release/bundle/macos/rustnote.app: code has no resources but signature indicates they must be presentThat is spctl failing to assess the bundle at all. And the failure is not academic. A download gets a quarantine attribute added by the browser, and a quarantined app in this state does not open — it dies:
cp -R src-tauri/target/release/bundle/macos/rustnote.app /tmp/rustnote.appxattr -w com.apple.quarantine "0083;00000000;Safari;|com.apple.Safari" /tmp/rustnote.app/tmp/rustnote.app/Contents/MacOS/rustnoteKilled: 9Exit code 137 — SIGKILL, from the kernel, before main runs. That is the “rustnote.app is damaged and can’t be opened” support ticket, reproduced honestly. Unsigned or half-signed, a downloaded app on Apple Silicon is dead on arrival. Fixing it takes one of two things, and the free one is not enough.
Updates
Section titled “Updates”The updater plugin solves a different problem — shipping fixes after install — but it starts with the same ingredient: a signature, this time one you own. Tauri signs update packages with a minisign-style keypair:
npm run tauri signer generate -- -w ~/.tauri/rustnote.key --password ""Your keypair was generated successfully:Private: /Users/rango/.tauri/rustnote.key (Keep it secret!)Public: /Users/rango/.tauri/rustnote.key.pub---------------------------
Environment variables used to sign:- `TAURI_SIGNING_PRIVATE_KEY`: Your private key. For the `build` and `bundle` command it can be either a string or a path to the file, for the `signer sign` command it must be the literal key string- `TAURI_SIGNING_PRIVATE_KEY_PATH`: Path to your private key file, used by the `signer sign` command- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`: Your private key password (optional if key has no password)
ATTENTION: If you lose your private key OR password, you'll not be able to sign your update package and updates will not workThe path in that output is this machine’s home directory; yours will print yours. Two rules matter more than any config: the private key never goes in the repository, and losing it means every existing install can never update.
Three edits to src-tauri/tauri.conf.json switch the build into producing signed update artifacts. First, inside the existing bundle object:
"createUpdaterArtifacts": trueSecond, a top-level plugins block whose pubkey is the contents of rustnote.key.pub, not its path:
"plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdFOTBDN0Q4OTZCRUI4OUQKUldTZHVMNlcyTWVRZmxaS3p2RDY5NGNPQS8rWmRyZ28rN1crVndBSHJOZGJSTXFPMVdHMVFybGwK", "endpoints": [ "https://rustnote-updates.example.com/latest.json" ] } }The endpoint is a stand-in — example.com is a reserved domain that will never serve anything. It stands for wherever you will host a latest.json feed. The updater never runs in this step, so the stand-in is inert; it just marks the slot.
Now rebuild, and hit the first trap immediately:
A public key has been found, but no private key. Make sure to set `TAURI_SIGNING_PRIVATE_KEY` environment variable. Error A public key has been found, but no private key. Make sure to set `TAURI_SIGNING_PRIVATE_KEY` environment variable.A public key in the config turns signing on, and signing needs the private key at build time. It is not a config file value — it is an environment variable, precisely so it can stay off your disk and out of your repository:
TAURI_SIGNING_PRIVATE_KEY=~/.tauri/rustnote.key TAURI_SIGNING_PRIVATE_KEY_PASSWORD= npm run tauri build Bundling rustnote.app (/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app) Bundling rustnote_1.0.0_aarch64.dmg (/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/dmg/rustnote_1.0.0_aarch64.dmg) Running bundle_dmg.sh Bundling /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app.tar.gz (/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app.tar.gz) Finished 2 bundles at: /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app (6.63 MiB) /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/dmg/rustnote_1.0.0_aarch64.dmg (3.14 MiB) /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app.tar.gz (updater) (3.14 MiB)
Finished 1 updater signature at: /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app.tar.gz.sigcreateUpdaterArtifacts changed the output: next to the regular bundles there is now rustnote.app.tar.gz (the archive the updater will download and unpack) and rustnote.app.tar.gz.sig, a detached minisign signature:
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVTZHVMNlcyTWVRZm1LT2FpUHo3a3ErSlU1Vk40R2hlZmNYRGlpWTNpN1JlQUlEZ3NtSk1oOHViRkVTNkJUZTdwTkFOUElUYnhaMFJsWjJiNDgzMjNFL0dxQnUyNkFHV0FvPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzkyNDYyNjA4CWZpbGU6cnVzdG5vdGUuYXBwLnRhci5negl2ZXJzaW9uOjEuMC4wCjNicE5EZ1FqOHpZRkcvWUNIejc0MGdLcnVwcnpSek1FZDlSTUg3U1lyUVJ0WWorSVlyU0dPNEhVMCtKY3N2OE9aaElRVWxmOEcyZzUvcjIwdmdqckNBPT0KThose two files plus a latest.json are the entire update feed format:
{ "version": "1.0.1", "notes": "Fixes the search highlight on long lines.", "pub_date": "2026-10-20T02:40:07Z", "platforms": { "darwin-aarch64": { "signature": "contents of rustnote.app.tar.gz.sig", "url": "https://rustnote-updates.example.com/rustnote_1.0.1_aarch64.app.tar.gz" } }}The signature field is the .sig file’s contents, verbatim — not a URL to it. version and pub_date are what the updater compares against the running app’s version. Host that file at the endpoint from the config, drop the tar.gz and its signature next to it on release day, and the feed exists.
The runtime half — the code inside the app that checks the feed — is one install command away:
npm run tauri add updater Info Installing Cargo dependency "tauri-plugin-updater"... Info Installing NPM dependency "@tauri-apps/plugin-updater@~2"... Info Capability matching platforms ["macOS", "windows", "linux"] not found, creating /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/capabilities/desktop.json Added permission `updater:default` to `desktop-capability` at /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/capabilities/desktop.json Info Enabled `plugins > updater > requireSignedVersion` in /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/tauri.conf.json Info Remember to set `plugins > updater > pubkey` and `endpoints`. Info Adding plugin to /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/src/lib.rs Info Running `cargo fmt`...One command, four files: the Rust crate and its npm counterpart, a new capability file granting updater:default to the main window, and the plugin registration in run() in src-tauri/src/lib.rs. It also switched on requireSignedVersion in the updater config, which leaves the plugins block looking like this:
"plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdFOTBDN0Q4OTZCRUI4OUQKUldTZHVMNlcyTWVRZmxaS3p2RDY5NGNPQS8rWmRyZ28rN1crVndBSHJOZGJSTXFPMVdHMVFybGwK", "endpoints": [ "https://rustnote-updates.example.com/latest.json" ], "requireSignedVersion": true } }rustnote now carries the updater runtime. What it does not carry is a call to check() — the JavaScript side of @tauri-apps/plugin-updater that would poll the endpoint on launch. There is no point calling it yet: the endpoint resolves to nothing until you host the feed. Wire the check when the feed is real.
Signing the bundle
Section titled “Signing the bundle”The updater keypair does nothing for Gatekeeper. What the quarantine test needs is a bundle signature, and the zero-cost version is the ad-hoc one — the pseudo-identity -:
APPLE_SIGNING_IDENTITY=- TAURI_SIGNING_PRIVATE_KEY=~/.tauri/rustnote.key TAURI_SIGNING_PRIVATE_KEY_PASSWORD= npm run tauri build Bundling rustnote.app (/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app) Signing with identity "-"Signing with identity "-"Signing /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app/Contents/MacOS/rustnote/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app/Contents/MacOS/rustnote: replacing existing signatureSigning with identity "-"Signing /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app: replacing existing signature Warn skipping app notarization, no APPLE_ID & APPLE_PASSWORD & APPLE_TEAM_ID or APPLE_API_KEY & APPLE_API_ISSUER & APPLE_API_KEY_PATH environment variables found Bundling rustnote_1.0.0_aarch64.dmg (/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/dmg/rustnote_1.0.0_aarch64.dmg) Running bundle_dmg.sh Bundling /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app.tar.gz (/Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app.tar.gz) Finished 2 bundles at: /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app (8.17 MiB) /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/dmg/rustnote_1.0.0_aarch64.dmg (3.98 MiB) /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app.tar.gz (updater) (4.00 MiB)
Finished 1 updater signature at: /Users/rango/www/hackingwithrust/code/tauri-desktop-app/src-tauri/target/release/bundle/macos/rustnote.app.tar.gz.sigThe Signing with identity "-" lines are new — the bundler ran codesign over the binary and then the bundle itself. Now the signature is real, in the limited sense that the whole bundle is sealed:
Identifier=com.rustnote.appCodeDirectory v=20500 size=16721 flags=0x10002(adhoc,runtime) hashes=516+3 location=embeddedSignature=adhocTeamIdentifier=not setSealed Resources version=2 rules=13 files=1Three changes from the linker-signed version: the identifier is your bundle identifier from step 2, Sealed Resources has entries, and the flags gained runtime — the hardening runtime macOS expects of distributed apps. The bundle also grew from 6.63 to 8.17 MiB: the sealed resources and hardened-runtime scaffolding are real bytes.
But run the quarantine test again and the result is the same: Killed: 9. And spctl -a -vv on the bundle now says the shortest thing in this article:
rejectedAn ad-hoc signature says “this bundle has not been modified since it was built”. It says nothing about who built it, and that is the only thing Gatekeeper cares about for downloads. The two-line Warn in the build output names what is missing: notarization, which needs APPLE_ID and APPLE_PASSWORD (an app-specific password) or the App Store Connect API keys — and needs a Developer ID Application certificate first, which needs a paid Apple Developer account. Careful with a distinction that costs people hours: the free “Apple Development” certificates Xcode offers are for running your own apps on your own devices; Apple’s docs are explicit that distribution outside the App Store requires the Developer ID certificate and, with it, notarization. The 8.17 MiB ad-hoc bundle is what you get for free, and it is exactly as far as free goes: right for local installs and CI smoke tests, not for a download link.
The other two platforms
Section titled “The other two platforms”Everything above ran on macOS, because that is what this machine is. The same npm run tauri build runs on the other platforms and reads the same config; what it produces and what it demands differ.
Windows. The bundler produces both an NSIS setup.exe and a WiX Toolset .msi — and the .msi can only be built on Windows, because WiX does not run anywhere else. The WebView2 runtime the app needs is part of Windows 10 (April 2018 or later) and Windows 11, so most users download nothing extra; the installer’s default webviewInstallMode (downloadBootstrapper) fetches it when missing. Signing needs a code-signing certificate — SSL certificates do not work — configured under bundle > windows with certificateThumbprint, digestAlgorithm, and a timestampUrl. One expectation to set honestly: since 2024, neither EV nor OV certificates bring the instant SmartScreen reputation they used to; newly signed installers from a fresh certificate can still warn until the certificate builds reputation across releases.
Linux. The bundler produces deb, rpm, and AppImage. The trap is not the formats — it is glibc. A binary links against the glibc of the machine that built it, so a build on a new distro will not run on older ones, with errors like /usr/lib/libc.so.6: version 'GLIBC_2.33' not found. Build on the oldest base you intend to support (Ubuntu 22.04 and Debian 12 both ship the libwebkit2gtk-4.1-dev Tauri needs), or do what most projects do and build in a pinned Docker image or CI. AppImages for ARM cannot be cross-compiled at all — the linuxdeploy tooling only runs on ARM hardware or an ARM runner.
Where the code stands
Section titled “Where the code stands”The step is tagged tauri-ch11 in the examples repository:
git clone https://github.com/hackingwithrust/examplescd examples/tauri-desktop-appgit checkout tauri-ch11npm installnpm run tauri devThis step touched package.json and package-lock.json (version, plus the updater npm package), src-tauri/Cargo.toml and Cargo.lock (version, plus tauri-plugin-updater), src-tauri/tauri.conf.json (version, createUpdaterArtifacts, the plugins block), src-tauri/src/lib.rs (the one plugin-registration line), and src-tauri/capabilities/desktop.json (new — the updater:default permission). The private signing key is not in the repository, and should never be.
To see the shipped artifact instead of the dev app, run the last build command from the signing section and open src-tauri/target/release/bundle/dmg/.
Twelve steps, one app
Section titled “Twelve steps, one app”Step 1 set the target; step 2 scaffolded the project; step 3 explained the three layers. Steps 4 through 7 built the spine — commands across the IPC boundary, SQLite persistence, typed queries, autosave — and step 8 made the notes searchable with FTS5. Steps 9 through 11 put real desktop behavior around the core: file import and drag-and-drop, tray and menus and shortcuts, export and print. This step closed the loop by making it installable and updatable.
What none of the twelve touched is the window itself: every step was exercised through a column of controls, because a command needs a caller long before the app needs a design. Step 13 is the one that turns those controls into the window the track has been describing since the first page.
The track overview links every article, and the tags tauri-ch02
through tauri-ch12 in the examples repository hold the project’s complete history, one runnable
state per step.