Shipping Tauri 2 Desktop Apps to Production with Antigravity — Code Signing, Auto-Updates, License Verification
After tauri build succeeds, the real work begins: notarization, EV signing, your own update server, license verification, and Stripe-driven monetization. A field guide built from running paid Tauri 2 apps in production with Antigravity.
Shipping Tauri 2 Desktop Apps to Production with Antigravity
Most developers who finish a Tauri 2 app discover the painful truth on release day: the hard part wasn't writing the code. It was everything that comes after tauri build returns successfully. Notarization rejections, SmartScreen warnings, broken updaters, license keys that don't survive offline use, Stripe webhooks firing twice — these are the topics that the official quickstart politely sidesteps, and they're exactly where every paid desktop app gets stuck.
I've shipped iPhone and Android apps as a solo developer for years, but desktop is a different animal. The store no longer handles distribution for you. You build the pipeline yourself, or you don't ship. This article walks through the full production deployment pattern I use for paid Tauri 2 apps, with Antigravity's agents doing the heavy lifting on the boilerplate so I can focus on the design decisions.
Why "Production Tauri 2" is Genuinely Hard
Tauri's edge over Electron isn't just smaller binaries and faster startup. It's distribution freedom. Tauri 2 generates native installers directly and lets you bring your own update infrastructure. That freedom is powerful — and it means everything is your responsibility.
In the Electron world, electron-updater and electron-builder automate most of this. Tauri 2 ships with tauri-plugin-updater, but key management, distribution endpoints, and staged rollout strategy are yours to build. The reason Antigravity matters here is that you can split this work across agents naturally: the Manager Surface lets one agent write the Rust signature-verification code while another writes the TypeScript license UI, and a third writes your Cloudflare Worker. That separation of concerns is the design philosophy that runs through this entire article.
A second reality: the failure modes of desktop distribution are completely different from web. A bad deploy on the web means rolling back in seconds; a bad release of a desktop app sits on customer machines until they relaunch. That changes how you design every layer. Your update server has to be conservative by default. Your license format has to survive across versions. Your installer has to be re-runnable without breaking existing data. None of these are obvious until your first hot-fix that has to ship without breaking anyone, and you discover the file you needed to update is owned by an installer that overwrites it.
The third thing that catches people: Tauri 2's smaller surface area cuts both ways. Yes, the binary is small and starts fast. But the ecosystem is younger than Electron's, which means many problems you'll hit don't have a Stack Overflow answer. This is exactly where Antigravity earns its keep — its agents can read the Tauri 2 source directly, propose fixes that match upstream's intent, and explain why a particular API exists rather than guessing at usage from forum posts. I've spent more than one evening watching the Manager Surface track down an issue across tauri-plugin-updater's Rust code, the Tauri JS bindings, and the underlying WebView crate — work that would have taken me a full day alone.
Code signing alone isn't enough on macOS. A signed .dmg or .app will still display "cannot verify the developer" until you submit it to Apple for notarization, then staple the result to the binary. Skip notarization and your users see a scary warning — and Gatekeeper will block the app entirely on certain configurations.
I run the entire pipeline through one script. When I asked Antigravity to "write notarize.sh that runs after tauri build, fails the GitHub Actions job on rejection, and posts the error log to Slack," it produced something close to this on the first pass:
#!/usr/bin/env bash# notarize.sh - Tauri 2 macOS notarization pipeline# Required env: APPLE_ID, APPLE_TEAM_ID, APPLE_APP_PASSWORD, SIGNING_IDENTITYset -euo pipefailAPP_PATH="src-tauri/target/release/bundle/macos/MyApp.app"DMG_PATH="src-tauri/target/release/bundle/dmg/MyApp_1.0.0_universal.dmg"# 1. Sign the .app — --options runtime is mandatory or notarization will rejectcodesign --force --deep --options runtime \ --sign "$SIGNING_IDENTITY" \ --entitlements src-tauri/entitlements.plist \ "$APP_PATH"# 2. Zip the bundle (notarization only accepts ZIP/DMG/PKG)ditto -c -k --keepParent "$APP_PATH" "MyApp.zip"# 3. Submit synchronously and wait for Apple's verdictxcrun notarytool submit "MyApp.zip" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ --password "$APPLE_APP_PASSWORD" \ --wait \ --output-format json > notarize_result.json || { SUBMISSION_ID=$(jq -r '.id' notarize_result.json 2>/dev/null || echo "unknown") echo "❌ Notarization failed: $SUBMISSION_ID" xcrun notarytool log "$SUBMISSION_ID" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ --password "$APPLE_APP_PASSWORD" || true exit 1 }# 4. Staple the ticket to both the .app and the DMGxcrun stapler staple "$APP_PATH"xcrun stapler staple "$DMG_PATH"# 5. Verify Gatekeeper accepts the resultspctl --assess --type execute --verbose=4 "$APP_PATH"echo "✅ Notarized: $DMG_PATH"
The two non-obvious requirements are --options runtime and the staple step. Forget the runtime flag and Apple rejects with "Hardened Runtime is not enabled," buried in the log. Forget stapling and offline users get blocked because Gatekeeper can't reach Apple's servers to verify. Both errors look like generic failures at first glance, so I have a Sub-Agent dedicated to parsing notarytool's JSON output and categorizing rejection reasons. That alone saves hours.
The expected output is accepted from notarytool, followed by spctl reporting source=Notarized Developer ID. If you see Unnotarized Developer ID, stapling failed — usually because the script tried to staple before notarization was actually complete.
A subtle thing about notarytool's behavior worth knowing: the --wait flag will sit and poll for up to about 15 minutes per submission. If your CI runner has a tight timeout, you may need to either increase the runner timeout or split into a non-blocking submit followed by a separate poll job. I went with the simpler --wait approach because every minute of CI complexity I added paid for itself only in marginal speed improvements, while the simpler version stayed reliable across hundreds of releases. Pick the option that minimizes the moving parts in your pipeline.
There's also a question of what to do when notarization is slow but ultimately succeeds. Apple's median turnaround is under three minutes, but I've seen 12-minute submissions during product launches and right after macOS dot releases ship. If you're publishing on a schedule (e.g. weekly Friday releases), staggering your notarization submissions away from common rush hours helps. The asymmetry: notarization has no SLA, so when Apple is slow, you wait. Build that variance into your release schedule rather than fighting it.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will get past the macOS notarization and Windows EV signing walls that block almost every Tauri 2 release, with reusable scripts you can drop into any pipeline today
✦You can stand up a Cloudflare Workers + R2 update server with staged rollout, rollback, and signature verification — production-grade, not a toy
✦You will be able to design a Stripe Checkout to offline-verifiable license flow that respects users while keeping casual piracy out of your revenue
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Windows Code Signing — The EV Certificate and SmartScreen Wall
On Windows, Microsoft Defender SmartScreen will loudly warn users about any unsigned installer. Even with a regular OV certificate, you'll see the warning until SmartScreen builds reputation — which can take weeks. An EV (Extended Validation) certificate establishes reputation immediately, but they cost ¥40,000–100,000 per year. My approach is to ship initially with an OV cert, build reputation, and upgrade to EV only if user warnings are actually hurting conversion.
EV certificates live on a USB token or HSM, which makes CI signing tricky. The cleanest solution is Azure Key Vault's Code Signing service — your CI calls a signing API instead of needing physical access to a token. The PowerShell script looks like this:
When Antigravity drafts this script for you, insist on parameterizing the timestamp server URL and configuring two fallback servers. DigiCert's timestamp server has gone down once or twice a month in my experience. Add http://timestamp.sectigo.com and http://timestamp.globalsign.com/tsa/r6advanced1 as fallbacks, and your build failures from this cause drop to nearly zero across a year.
A Self-Hosted Update Server on Cloudflare Workers + R2
Tauri 2's tauri-plugin-updater reads a JSON manifest, compares versions, and downloads the new binary. You could host the manifest on GitHub Releases — many tutorials suggest this — but I push back. Cloudflare Workers + R2 wins on three points:
First, staged rollout (10% → 50% → 100%) is trivial Worker logic. Second, future requirements like region-specific distribution or A/B-tested updates slot in cleanly. Third, R2 has zero egress fees, so your binary distribution costs are essentially the storage bill.
Here's the minimal Worker. When delegating this to Antigravity, I always specify "rollout percentage lives in KV, SHA256 hashes come from R2 metadata" — that constraint produces code that's resilient to operational changes:
// src/index.ts - Tauri 2 update manifest endpointinterface Env { UPDATES_KV: KVNamespace; UPDATES_R2: R2Bucket; UPDATE_SIGNATURE_PUBLIC_KEY: string;}interface UpdateMeta { version: string; notes: string; pub_date: string; rollout: number; // 0-100 platforms: Record<string, { url: string; signature: string }>;}export default { async fetch(req: Request, env: Env): Promise<Response> { const url = new URL(req.url); const match = url.pathname.match(/^\/api\/update\/([^/]+)\/([^/]+)$/); if (!match) return new Response("Not Found", { status: 404 }); const [, platform, currentVersion] = match; const userBucket = hashToBucket(req.headers.get("x-client-id") ?? crypto.randomUUID()); try { const metaJson = await env.UPDATES_KV.get("latest_meta", "json") as UpdateMeta | null; if (!metaJson) return new Response("No update available", { status: 204 }); // Staged rollout gate if (userBucket > metaJson.rollout) { return new Response("Not eligible for rollout", { status: 204 }); } // Skip if client is already up to date if (compareSemver(currentVersion, metaJson.version) >= 0) { return new Response("Up to date", { status: 204 }); } const platformData = metaJson.platforms[platform]; if (!platformData) { return new Response(`Unsupported platform: ${platform}`, { status: 400 }); } // Tauri Updater's expected response shape return Response.json({ version: metaJson.version, notes: metaJson.notes, pub_date: metaJson.pub_date, platforms: { [platform]: platformData }, }); } catch (err) { console.error("Update endpoint error:", err); // Return 204 on error so the client treats it as "no update" return new Response("Internal error (silenced)", { status: 204 }); } },};function hashToBucket(id: string): number { let hash = 0; for (let i = 0; i < id.length; i++) { hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0; } return Math.abs(hash) % 100;}function compareSemver(a: string, b: string): number { const parse = (v: string) => v.replace(/^v/, "").split(".").map(Number); const [a1, a2, a3] = parse(a); const [b1, b2, b3] = parse(b); return a1 - b1 || a2 - b2 || a3 - b3;}
There's a deliberate design choice here that's worth flagging. When the Worker errors, I return 204 (No Content) instead of 500. Tauri Updater treats anything other than 200 as "no update available." Returning 500 would surface "Failed to check for updates" dialogs to users every time something hiccups upstream. Quietly degrading to "no update right now" is the correct UX for a background updater.
The expected behavior: when a new version exists, Tauri Updater receives the JSON, downloads the binary with SHA256 verification, and proceeds to install. Promoting from 10% to 50% to 100% rollout is just a KV write — no redeploys, no manifest re-uploads.
Don't forget to design rollback. I keep the previous version's manifest under a previous_meta KV key, so I can roll back by swapping two key values. R2 binaries are never deleted and URLs never change. That immutability is what makes rollback safe.
A pragmatic operational tip about KV updates: I always update the manifest by writing to a versioned key first (meta_v42), then atomically updating the latest_meta pointer to that version. That gives me a write-ahead log of every release I've ever shipped, and rollback becomes "point latest_meta back to meta_v41" — no rebuilding, no rebundling, just a KV write. With Cloudflare's eventual-consistency guarantees, the change propagates globally within a minute, which is fast enough for emergency rollbacks while being safer than directly mutating the active key.
The other thing worth designing carefully is the previous_meta semantics. I keep the prior release accessible at the dedicated key for at least 30 days, separately from the versioned history. The versioned history exists for archival and rollback; previous_meta exists for graceful degradation if the latest manifest gets corrupted, allowing the Worker to fall back transparently. These look like the same data but they answer different operational questions, and conflating them has bitten me before.
Offline-Capable License Verification
Paid desktop apps face a real tension: users expect to launch the app on planes and trains without internet, but you also want some defense against casual piracy. The pattern I've settled on is server-signed payload + client-side verification. No constant phoning home, no "you're offline, please connect" friction.
The flow: Stripe checkout completes → server signs a license payload (user ID + expiry + device fingerprint) with a private key → client embeds the matching public key → app verifies the signature on launch. Offline-friendly, stateless, no online dependency.
The non-obvious decision here is how to compute the device fingerprint. MAC addresses are trivially spoofed by virtual NICs. Hard drive serial numbers break in dual-boot setups. I use the OS-level install UUID — IOPlatformUUID on macOS, MachineGuid on Windows — combined and SHA256-hashed. It's not bulletproof, but it stops casual sharing among friends and family. Going further than that gets into pixel-counting paranoia that costs more in user friction than it gains in piracy prevention.
You'll notice I don't maintain a server-side license revocation list. That's a deliberate trade-off. Revocation lists demand an online check, which destroys the "works offline" property. They also complicate refund automation. For genuinely abusive cases, I bake a blacklist of revoked license IDs into the next app release — slow, but doesn't require running an authoritative server.
Stripe Checkout to License Issuance End-to-End
The other critical piece is wiring up Stripe webhooks to the license issuance flow. A Cloudflare Worker receives the webhook, generates the license, and uses Resend or Postmark to email it to the buyer. With Antigravity splitting this across agents, the entire flow takes about an hour to build.
The single most important detail: idempotency. Write the Stripe event.id to KV before processing, and skip if it already exists. Stripe legitimately retries webhooks, and double-issuance is a customer support disaster. Just as importantly, build a "resend my license" endpoint from day one — let users get their license re-emailed without contacting support, and your support load drops dramatically.
One more design decision worth highlighting: I generate licenses with a 30-day "grace period" for annual plans. The expires_at timestamp is set 30 days past the actual subscription end date. The reasoning is empathy — when a user's payment method expires, you don't want to brick their app the same day. Send them a reminder, give them time to update billing, and only enforce the cutoff if they ignore three weeks of email. This single decision dropped my refund rate noticeably; people who were going to renew anyway just needed a few days, and people who genuinely wanted to cancel weren't surprised by anything.
For monthly subscriptions, I take a different approach: instead of issuing a license with a 30-day expiry every month, I issue a license that expires 75 days out and have the app silently re-fetch a fresh one every 30 days when online. This means a user who goes offline for 2 months still has a working app, and only after that do they need to reconnect to refresh the license. The trade-off is more server hits per month, but R2 + Workers handle that scale for pennies.
For the Stripe-side details (Checkout localization, Webhook signature verification under Cloudflare Workers), the Antigravity × App Monetization with Stripe Guide has the full picture. Combine its Stripe integration with this article's Tauri-side license verification and you have a complete commercial distribution system.
Pitfalls You Will Hit (Field-Tested)
Five pitfalls I learned the hard way. None are well documented in the official Tauri docs.
Updater public key changes. If you regenerate tauri signer generate keys in CI, every existing user's app will reject your updates as "invalid signature." Generate the key once, back it up obsessively, and when you need to rotate, ship a transitional release that's signed with both the old and new keys.
Universal Binary RPATH issues on macOS. Building Intel and Apple Silicon separately, then merging with lipo, can embed mismatched RPATHs that crash on one architecture. Use cargo-zigbuild or Tauri's --target universal-apple-darwin to keep both architectures in the same toolchain.
SmartScreen reputation reset on Windows. This one cost me three weeks. If you distribute your signed .exe inside a .zip, Windows applies "Mark of the Web" to the inner executable and treats it as untrusted again, even though it's signed. Distribute the signed installer directly, never zipped.
License keys stored in plaintext config. Always use the OS keychain — Keychain on macOS, Credential Manager on Windows, Secret Service on Linux. Tauri 2 has tauri-plugin-stronghold, which gives you encrypted local storage with a clean API.
Untestable updaters. Don't believe anyone who says you can only test the updater by actually shipping a release. Ask Antigravity to "spin up a local Worker with a stub manifest endpoint," and you'll have a development loop that exercises the updater from your dev machine. Before shipping, walk through five cases: same-version (no update), minor update, major update, key mismatch, and network disconnected.
One additional pitfall worth flagging because it bites every team eventually: treating "release" as a single atomic event. In practice, building, signing, notarizing, uploading to R2, and publishing the manifest are five separate operations across two operating systems. Any one can fail independently. I structure my GitHub Actions workflow so each stage produces an artifact that subsequent stages consume. If notarization fails, I haven't already announced the release on the manifest. If R2 upload fails, the manifest update never runs. This pipeline shape — not a single big script — is what lets you sleep through a botched release.
A related operational mindset: I never push manifest updates manually. Even when I'm about to deploy a hotfix at 2am because something is broken, I run the same pipeline. Manual ops are fine when nothing's wrong; under stress, they're how you ship the wrong binary or forget to staple a notarization ticket. Antigravity helped me lock this in by writing the entire workflow in a way that the local dev loop produces the exact same artifacts as CI — that symmetry is what keeps the release process trustworthy.
Real-World Application
This is the architecture I run for several apps in production: an AI-assisted writing tool, a lightweight SQL client, and a local-first image generation GUI. They share three properties: they have to run well offline, they have a "buy once" or "annual" pricing model, and they ship to both Mac and Windows.
One thing I cannot recommend enough: document this entire production deployment design in your project's AGENTS.md. Six months from now, when you're modifying the updater and start wondering "why didn't I add a server-side revocation list," your past self will have written down the answer. My AGENTS.md includes the pitfall list above almost verbatim, so future-me — and Antigravity's agents reading the file — don't fall into the same traps.
A practical observation about pricing: I've found that buy-once licenses outperform subscriptions for desktop apps in the indie space. Web apps train users to expect monthly billing; desktop apps still feel like "products you buy." My own data: the same app sold at $39 lifetime converts at roughly 2.3x the rate of $5/month, even though long-term revenue is comparable. Your mileage will vary, but worth A/B testing rather than assuming subscriptions are universally better.
I'll add one more architectural note. The license verification code lives in src-tauri/src/license.rs for a reason — it's Rust, not JavaScript. A determined attacker could patch a JavaScript verifier inside the WebView, but patching Rust requires modifying the binary itself, which invalidates the code signature. The Rust path forces them to also re-sign the binary, which means re-distributing the modified app. That additional friction stops most casual cracking attempts at the boundary, and it's a free benefit of how Tauri is structured.
If I had to compress this whole article into one line: shipping a Tauri 2 app to production takes more time designing the system than writing the app code. Signing, notarization, updates, licensing, payments — each is its own small project. The reason Antigravity makes this tractable for solo developers is that the agent-oriented workflow lets you parallelize the boilerplate across all of these systems at once.
If you do one thing today, add the macOS notarization script to your existing project. The moment that script lands in your CI, your app crosses the line from "side project" to "product I can sell." Hook it into GitHub Actions so cutting a release tag produces a notarized build automatically — that single piece of infrastructure changes how you think about your project. From there, work outward to the other pieces, and pair this article with the Antigravity × App Monetization with Stripe Guide to round out the commercial side.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.