DEV Community

Cover image for Shipping a Production macOS App with Tauri 2.0: Code Signing, Notarization, and Homebrew
Massi
Massi

Posted on

Shipping a Production macOS App with Tauri 2.0: Code Signing, Notarization, and Homebrew

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:

  1. Code signing: proves the app comes from a verified developer
  2. Notarization: Apple scans the binary for malware and issues a ticket
  3. Distribution: a way for users to install it (Homebrew, DMG, or both)
  4. 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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Builds the Swift sidecar (if you have one) as a universal binary
  2. Builds the Tauri app for both aarch64-apple-darwin and x86_64-apple-darwin
  3. Signs the binary with your Developer ID
  4. Submits it to Apple for notarization
  5. Uploads the signed .dmg to GitHub Releases
  6. 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

For example:

src-tauri/binaries/darwinkit-aarch64-apple-darwin
src-tauri/binaries/darwinkit-x86_64-apple-darwin
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Generate the key pair with:

npx @tauri-apps/cli signer generate -w ~/.tauri/stik.key
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That's it. From here, everything is automated:

  1. GitHub Actions detects the tag
  2. Builds the Swift sidecar as a universal binary (arm64 + x86_64)
  3. Builds the Tauri app for both architectures
  4. Signs both builds with my Developer ID certificate
  5. Submits both to Apple for notarization (takes 2-5 minutes)
  6. Staples the notarization tickets
  7. Uploads the .dmg files and latest.json to a new GitHub Release
  8. 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
Enter fullscreen mode Exit fullscreen mode

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)