There are plenty of tutorials on building a Tauri app. Very few tell you what happens after npm run tauri build.
I recently shipped Stik, a note-capture app for macOS built with Tauri 2.0. The app itself took a few days to build. Getting it properly signed, notarized, distributed through Homebrew, and auto-updating took longer than I expected.
This post covers everything I learned. If you're building a Tauri app and plan to ship it to real users on macOS, this should save you a few days of pain.
The problem
You've built your Tauri app. It runs great in tauri dev. You run tauri build and get a .dmg. You send it to a friend. They open it and macOS says:
"App is damaged and can't be opened. You should move it to the Trash."
That's because your app isn't code signed or notarized. Apple requires both for any app distributed outside the App Store. Without them, macOS Gatekeeper blocks your app on every machine except yours.
This is where most Tauri tutorials stop and most developers get stuck.
What you actually need
Getting a Tauri app to users on macOS requires four things beyond building the binary:
- Code signing: proves the app comes from a verified developer
- Notarization: Apple scans the binary for malware and issues a ticket
- Distribution: a way for users to install it (Homebrew, DMG, or both)
- Auto-updates: so users don't get stuck on old versions forever
Let's go through each one.
Step 1: Apple Developer setup
You need an Apple Developer account ($99/year). There's no way around this for distribution outside the App Store.
Once enrolled, you need two things.
A Developer ID Application certificate. Go to Certificates, Identifiers & Profiles in your developer account. Create a "Developer ID Application" certificate. Download it and install it in your Keychain. This is what signs your app.
An app-specific password. Go to appleid.apple.com, sign in, and generate an app-specific password under Security. This is used by the notarization tool to authenticate with Apple's servers.
Export your signing certificate as a .p12 file from Keychain Access. You'll need it for CI.
Step 2: Configure Tauri for signing
In your tauri.conf.json, make sure the bundle identifier is set:
{
"bundle": {
"identifier": "ink.stik.app",
"macOS": {
"signingIdentity": "Developer ID Application: Your Name (TEAMID)",
"entitlements": "./Entitlements.plist"
}
}
}
Create an Entitlements.plist in your src-tauri/ directory:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
These entitlements are needed because Tauri uses a WebView that requires JIT compilation. Without them, the app will crash on launch after notarization.
Step 3: The CI/CD pipeline
This is where it all comes together. One GitHub Actions workflow, triggered by a git tag, does everything:
- Builds the Swift sidecar (if you have one) as a universal binary
- Builds the Tauri app for both
aarch64-apple-darwinandx86_64-apple-darwin - Signs the binary with your Developer ID
- Submits it to Apple for notarization
- Uploads the signed
.dmgto GitHub Releases - Updates the Homebrew tap
Here's the structure:
name: Release
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Import Apple signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH=$RUNNER_TEMP/certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain
- name: Build DarwinKit universal binary
run: |
cd src-tauri/darwinkit
swift build -c release --arch arm64 --arch x86_64
mkdir -p ../binaries
BINARY=$(find .build -name darwinkit -type f -perm +111 | grep -i release | head -1)
echo "Found binary at: $BINARY"
cp "$BINARY" ../binaries/darwinkit-aarch64-apple-darwin
cp "$BINARY" ../binaries/darwinkit-x86_64-apple-darwin
- name: Install npm dependencies
run: npm ci
- name: Build and release (aarch64)
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: 'Stik v__VERSION__'
releaseBody: 'See [CHANGELOG](https://github.com/0xMassi/stik_app/blob/main/CHANGELOG.md) for details.'
releaseDraft: true
prerelease: false
args: --target aarch64-apple-darwin
- name: Build and release (x86_64)
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: 'Stik v__VERSION__'
releaseDraft: true
prerelease: false
args: --target x86_64-apple-darwin
- name: Update Homebrew tap
if: success()
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
# Download DMGs using repo-scoped GITHUB_TOKEN
GH_TOKEN="$GITHUB_TOKEN" gh release download "$GITHUB_REF_NAME" --pattern "*.dmg" --dir "$RUNNER_TEMP"
SHA_ARM=$(shasum -a 256 "$RUNNER_TEMP/Stik_${VERSION}_aarch64.dmg" | cut -d' ' -f1)
SHA_INTEL=$(shasum -a 256 "$RUNNER_TEMP/Stik_${VERSION}_x64.dmg" | cut -d' ' -f1)
# Generate updated cask formula
cat > "$RUNNER_TEMP/stik.rb" <<CASKEOF
cask "stik" do
arch arm: "aarch64", intel: "x64"
version "${VERSION}"
sha256 arm: "${SHA_ARM}",
intel: "${SHA_INTEL}"
url "https://github.com/0xMassi/stik_app/releases/download/v#{version}/Stik_#{version}_#{arch}.dmg"
name "Stik"
desc "Instant thought capture - one shortcut, post-it appears, type, close"
homepage "https://github.com/0xMassi/stik_app"
depends_on macos: ">= :catalina"
app "Stik.app"
zap trash: [
"~/Documents/Stik",
"~/.stik",
"~/Library/Caches/com.stik.app",
"~/Library/WebKit/com.stik.app",
]
end
CASKEOF
# Base64-encode the cask content
CONTENT=$(base64 -i "$RUNNER_TEMP/stik.rb")
# Get current file SHA from GitHub API (needed for update)
FILE_SHA=$(GH_TOKEN="$HOMEBREW_TAP_TOKEN" gh api repos/0xMassi/homebrew-stik/contents/Casks/stik.rb --jq '.sha')
# Push updated cask to tap repo
GH_TOKEN="$HOMEBREW_TAP_TOKEN" gh api repos/0xMassi/homebrew-stik/contents/Casks/stik.rb \
--method PUT \
-f message="Update Stik to v${VERSION}" \
-f sha="$FILE_SHA" \
-f content="$CONTENT"
- name: Trigger landing page rebuild
if: success()
env:
VERCEL_DEPLOY_HOOK: ${{ secrets.VERCEL_DEPLOY_HOOK }}
run: curl -s -X POST "$VERCEL_DEPLOY_HOOK"
The secrets you need
In your GitHub repo settings, add these secrets:
| Secret | What it is |
|---|---|
APPLE_CERTIFICATE |
Your .p12 certificate, base64 encoded |
APPLE_CERTIFICATE_PASSWORD |
The password you set when exporting the .p12
|
APPLE_SIGNING_IDENTITY |
Developer ID Application: Your Name (TEAMID) |
APPLE_ID |
Your Apple ID email |
APPLE_PASSWORD |
The app-specific password from Step 1 |
APPLE_TEAM_ID |
Your 10-character team ID |
To base64 encode your certificate:
base64 -i Certificates.p12 | pbcopy
What tauri-action does for you
The tauri-apps/tauri-action GitHub Action handles most of the hard work. When you provide the Apple environment variables, it automatically:
- Imports the certificate into a temporary keychain on the runner
- Signs the app bundle with your Developer ID
- Submits the app to Apple's notarization service
- Staples the notarization ticket to the
.dmg - Uploads the result to GitHub Releases
This saves you from writing hundreds of lines of codesign and xcrun notarytool commands yourself.
The sidecar naming problem
If you're using a Swift (or any other) sidecar binary, Tauri expects a very specific naming convention:
src-tauri/binaries/{name}-{target-triple}
For example:
src-tauri/binaries/darwinkit-aarch64-apple-darwin
src-tauri/binaries/darwinkit-x86_64-apple-darwin
If the name doesn't match exactly, Tauri won't bundle it and you'll get a runtime error when trying to spawn the sidecar. This cost me hours of debugging.
Step 4: Homebrew distribution
Homebrew is the standard way developers install tools on macOS. Getting your app into Homebrew makes installation a one-liner:
brew install --cask stik
Creating a Homebrew tap
A tap is a GitHub repository that contains your Homebrew formula. Create a repo named homebrew-{name} (for example, homebrew-stik).
Inside it, create Casks/stik.rb:
cask "stik" do
arch arm: "aarch64", intel: "x64"
version "0.4.0"
sha256 arm: "SHA256_ARM64_HERE",
intel: "SHA256_X64_HERE"
url "https://github.com/0xMassi/stik_app/releases/download/v#{version}/Stik_#{version}_#{arch}.dmg"
name "Stik"
desc "Instant thought capture for macOS"
homepage "https://www.stik.ink"
app "Stik.app"
zap trash: [
"~/Documents/Stik",
"~/.stik",
]
end
Tap vs Homebrew Core
With a tap, users install with brew install 0xMassi/stik/stik. To get into Homebrew Core (just brew install --cask stik), you need to meet their inclusion criteria: the app needs to be notable, actively maintained, and have enough users. Start with a tap, submit to Core once you have traction.
Step 5: Auto-updates
If you ship v0.3.0 without an auto-updater, your early users are stuck there forever unless they manually check for updates. I learned this the hard way. I shipped the auto-updater in v0.3.3, which meant my first 100+ users needed a manual update to get it.
Tauri has a built-in updater plugin. Add it to your Cargo.toml:
[dependencies]
tauri-plugin-updater = "2"
Configure it in tauri.conf.json:
{
"plugins": {
"updater": {
"endpoints": [
"https://github.com/0xMassi/stik_app/releases/latest/download/latest.json"
],
"pubkey": "YOUR_PUBLIC_KEY_HERE"
}
}
}
Generate the key pair with:
npx @tauri-apps/cli signer generate -w ~/.tauri/stik.key
Store the private key as a GitHub secret (TAURI_SIGNING_PRIVATE_KEY) and add the public key to tauri.conf.json. The CI pipeline will automatically sign the update bundle during build.
The latest.json file is generated by tauri-action and uploaded to your GitHub Release. It contains the download URL and signature for each platform.
On the Rust side, check for updates on app launch:
use tauri_plugin_updater::UpdaterExt;
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| {
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
if let Ok(Some(update)) = handle.updater().check().await {
let _ = update.download_and_install(|_, _| {}, || {}).await;
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error running app");
}
The update downloads in the background and applies on the next restart. No user interaction needed.
The full release flow
Here's what happens when I'm ready to ship a new version:
# 1. Update version in package.json and Cargo.toml
# 2. Update CHANGELOG.md
# 3. Commit
git tag v0.4.0
git push origin v0.4.0
That's it. From here, everything is automated:
- GitHub Actions detects the tag
- Builds the Swift sidecar as a universal binary (arm64 + x86_64)
- Builds the Tauri app for both architectures
- Signs both builds with my Developer ID certificate
- Submits both to Apple for notarization (takes 2-5 minutes)
- Staples the notarization tickets
- Uploads the
.dmgfiles andlatest.jsonto a new GitHub Release - Updates the Homebrew tap with new version and SHA256 hashes
Total time: about 15 minutes. Manual steps: one git tag.
Things I wish I knew earlier
Notarization can be slow. Apple's notarization service usually takes 2-5 minutes but can sometimes take 15-20 minutes. Your CI workflow needs to handle this. tauri-action polls automatically, but set a reasonable timeout.
The certificate expires. Developer ID certificates are valid for 5 years. Set a calendar reminder. If it expires, your CI pipeline breaks silently and you ship unsigned builds.
Universal binaries for sidecars. If you have a Swift sidecar, you need to build it as a universal binary (--arch arm64 --arch x86_64) so it works on both Intel and Apple Silicon Macs. Tauri won't do this for you. It only handles the Rust binary.
Test the signed build locally first. Before setting up CI, do one manual signing and notarization run on your machine. It's much easier to debug when you can see the output directly:
# Sign
codesign --deep --force --verify --verbose \
--sign "Developer ID Application: Your Name (TEAMID)" \
--options runtime \
--entitlements Entitlements.plist \
target/release/bundle/macos/YourApp.app
# Notarize
xcrun notarytool submit target/release/bundle/macos/YourApp.dmg \
--apple-id you@email.com \
--password xxxx-xxxx-xxxx-xxxx \
--team-id XXXXXXXXXX \
--wait
# Staple
xcrun stapler staple target/release/bundle/macos/YourApp.dmg
Ship the auto-updater from day one. Every user who downloads your app before the updater exists becomes a user you can't update automatically. Don't make my mistake.
Entitlements matter. If your app crashes right after notarization but works fine unsigned, it's almost certainly an entitlements issue. Tauri's WebView needs JIT and unsigned executable memory permissions. Check the entitlements section above.
Was it worth it?
Setting up this pipeline took about two days of trial and error. But since then, every release is a single command. I've shipped 4 versions in a week with zero friction.
If you're building a Tauri app and planning to distribute it to real users, invest in this infrastructure early. The time you spend on CI/CD pays for itself after the second release.
The full source is available at github.com/0xMassi/stik_app, including the complete GitHub Actions workflow. MIT licensed.
If you have questions about any of this, drop a comment or open an issue on the repo. Happy to help.
Top comments (0)