Let's Connect

Three laptops running different operating systems side by side, representing a Tauri app packaged for Windows, macOS, and Linux

Tauri packaging cross-platform sounds like one command, and `tauri build` is one command, but it only ever builds for the OS it runs on. There is no honest way to cross-compile a signed, notarized macOS `.dmg` from a Linux box, and you should stop trying. The fix is to stop cross-compiling and start running `tauri build` on each native OS through a CI matrix. Each of the three targets has its own bundle format, its own signing regime, and its own way to fail silently. Get those three things straight and you can ship Windows, macOS, and Linux from one GitHub repo without owning three machines. I have shipped Tauri v2 apps this way; here is exactly what each platform needs.

What does tauri build actually produce on each OS?

`tauri build` compiles your Rust binary and then hands it to a bundler that wraps it in the installer format native to the current OS. The same source produces a completely different artifact depending on where it runs. Windows gives you an MSI (via WiX) or an NSIS `.exe` setup. macOS gives you a `.app` bundle and a `.dmg` disk image. Linux gives you a `.deb` and an `.AppImage`. You control which of these get built with the `bundle.targets` array in your config.

  • Windows: `msi` (WiX Toolset v3, per-machine install) and/or `nsis` (a `.exe` installer that supports per-user install and is generally friendlier for auto-update). NSIS is the one I reach for first.
  • macOS: `app` (the `.app` directory users drag to Applications) and `dmg` (the distributable disk image). You almost always ship the `.dmg`.
  • Linux: `deb` for Debian/Ubuntu and `appimage` for a portable single-file binary that runs on most distros. `rpm` is also available if you target Fedora/RHEL.
  • `all` is valid but only resolves to the formats that make sense on the current OS — it will not magically build a `.dmg` on Linux.

The trap is assuming `bundle.targets` is a wishlist that one machine fulfils. It is not. On a Linux runner, asking for `msi` is simply ignored. The targets array is filtered by the host OS, which is precisely why the CI matrix below exists.

How do I configure the bundle targets in tauri.conf.json?

In Tauri v2 the bundler lives under the top-level `bundle` key (note: not `tauri.bundle` as in v1 — this is one of the v2 breaking changes that trips up older tutorials). You set `active: true`, list your `targets`, and configure the updater artifacts and OS-specific blocks. Here is a realistic v2 config covering all three platforms plus the updater.

src-tauri/tauri.conf.json
{
  "productName": "sshpilot",
  "version": "1.4.0",
  "identifier": "bd.ryn.sshpilot",
  "bundle": {
    "active": true,
    "targets": ["nsis", "msi", "app", "dmg", "deb", "appimage"],
    "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico", "icons/icon.icns"],
    "createUpdaterArtifacts": true,
    "windows": {
      "nsis": { "installMode": "perUser" }
    },
    "macOS": {
      "minimumSystemVersion": "10.15",
      "signingIdentity": "Developer ID Application: Your Name (TEAMID)"
    },
    "linux": {
      "deb": { "depends": ["libwebkit2gtk-4.1-0", "libgtk-3-0"] }
    }
  },
  "plugins": {
    "updater": {
      "endpoints": ["https://releases.ryn.bd/sshpilot/{{target}}/{{arch}}/{{current_version}}"],
      "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6...your-base64-public-key..."
    }
  }
}

Two fields matter most for distribution. `createUpdaterArtifacts: true` tells the bundler to emit the signed `.sig` files the built-in updater consumes. And `linux.deb.depends` is the line people forget — Tauri v2 links against `webkit2gtk-4.1` (v1 used 4.0), and if you omit it your `.deb` installs fine and then refuses to launch on a clean machine with a cryptic shared-library error. Declare the dependency so apt pulls it in.

Why do my installers warn users, and what stops it?

An unsigned installer is the single biggest reason a working build still feels broken to your users. The binary is identical; the operating system just refuses to trust it. The two paid platforms each have their own gate, and — this is the part tutorials get wrong — neither uses the same key as the Tauri updater. The updater signs with a minisign keypair from `tauri signer generate`; OS code signing uses a real certificate. Two different mechanisms, two different sets of environment variables.

Windows: code-signing certificate vs SmartScreen

An unsigned Windows installer triggers a full-screen Microsoft Defender SmartScreen warning that most users read as 'this is malware'. You need an Authenticode code-signing certificate. A standard OV certificate signs the binary but still has to build reputation before SmartScreen stops warning; an EV certificate gets instant SmartScreen reputation but requires a hardware token or cloud HSM. Tauri signs during the build when you supply the certificate. In CI you provide a base64-encoded PFX and its password as `WINDOWS_CERTIFICATE` and `WINDOWS_CERTIFICATE_PASSWORD` — these are distinct from the `TAURI_SIGNING_*` keys that sign updater artifacts.

Windows Authenticode signing env (set as CI secrets)
# Tauri reads these during `tauri build` on the windows runner to
# Authenticode-sign the MSI/NSIS installers. These are NOT the updater keys.
# Never commit the PFX or its password — these come from repo secrets.
export WINDOWS_CERTIFICATE="$WINDOWS_CERT_BASE64"        # base64 of the .pfx
export WINDOWS_CERTIFICATE_PASSWORD="$WINDOWS_CERT_PW"

# To create the base64 locally from a .pfx:
#   base64 -w0 my-cert.pfx > cert.b64
#
# Separately, the auto-updater signs its artifacts with a minisign key:
#   export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/sshpilot.key)"
#   export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_KEY_PW"
# Those two are for the updater only — do not confuse them with the PFX above.

macOS: Developer ID signature plus notarization

macOS Gatekeeper is stricter. Signing with a `Developer ID Application` certificate is necessary but not sufficient — since Catalina you also have to notarize the app by uploading it to Apple, which scans it and issues a ticket you staple to the `.dmg`. Skip notarization and Gatekeeper quarantines the download; users see 'cannot be opened because the developer cannot be verified' with no obvious override. Tauri's bundler runs notarization for you on the macOS runner when you provide your Apple credentials as environment variables.

macOS signing + notarization env (CI secrets)
export APPLE_CERTIFICATE="$MACOS_CERT_BASE64"          # base64 of the .p12
export APPLE_CERTIFICATE_PASSWORD="$MACOS_CERT_PW"
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)"

# Notarization — use an App Store Connect API key (cleaner than Apple-ID auth):
export APPLE_API_ISSUER="$ASC_ISSUER_ID"               # issuer UUID
export APPLE_API_KEY="$ASC_KEY_ID"                     # the Key ID, not the key file
export APPLE_API_KEY_PATH="$RUNNER_TEMP/AuthKey.p8"    # path to the downloaded .p8

Linux, for what it is worth, has no equivalent gatekeeper. Your `.deb` and `.AppImage` install and run unsigned. You can optionally GPG-sign a `.deb` for apt repositories, but nothing blocks an unsigned Linux build the way SmartScreen and Gatekeeper block the others. That asymmetry is genuinely the simplest thing about shipping to Linux.

A rack of server hardware with network cabling, representing GitHub Actions runners building a Tauri release on each native operating system
Each OS in the matrix gets its own native runner. Windows signs and bundles on Windows, macOS notarizes on macOS — no cross-compilation of the parts that hate it.

How do I build all three from one GitHub Actions workflow?

Run `tauri build` on a native runner per OS with a job matrix, so the Windows MSI is bundled on Windows and the macOS `.dmg` is notarized on macOS. The official `tauri-apps/tauri-action` handles installing the toolchain, building, and creating a draft GitHub Release with the artifacts attached. The one Linux-specific gotcha is installing the WebKitGTK system libraries before the build, or the Rust compile fails to link.

.github/workflows/release.yml
name: release
on:
  push:
    tags: ['v*']

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: ubuntu-22.04
          - platform: macos-latest   # arm64 (Apple Silicon)
          - platform: windows-latest

    runs-on: ${{ matrix.platform }}
    steps:
      - uses: actions/checkout@v4

      # WebKitGTK + build deps — Linux only, or the link step fails.
      - if: matrix.platform == 'ubuntu-22.04'
        run: |
          sudo apt-get update
          sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
            librsvg2-dev patchelf build-essential

      - uses: dtolnay/rust-toolchain@stable
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci

      - uses: tauri-apps/tauri-action@v0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # Auto-updater signing (minisign key — runs on every OS):
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PASSWORD }}
          # Windows Authenticode signing (only consumed on the windows runner):
          WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
          WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
          # macOS signing + notarization (only consumed on the macos runner):
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
          APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
          APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
          APPLE_API_KEY_PATH: ${{ secrets.APPLE_API_KEY_PATH }}
        with:
          tagName: ${{ github.ref_name }}
          releaseName: 'sshpilot ${{ github.ref_name }}'
          releaseDraft: true

Each runner only consumes the secrets that apply to it — the Windows certificate is inert on the macOS job and vice versa, so it is safe to list them all in one env block. Set `fail-fast: false` so one platform's failure does not cancel the other two — when notarization is being slow, I still want the Windows and Linux artifacts to land. The whole thing is the same matrix idea I use for backend deploys; if GitHub Actions itself is new to you, I walked through the fundamentals in my CI/CD pipeline with GitHub Actions post.

Cross-compiling a notarized macOS build from Linux is a tarpit. Give each OS its own native runner and the painful 20% of packaging — signing and notarization — stops being your problem.Md Raihan Hasan

Where does the auto-updater fetch its manifest?

Tauri's built-in updater plugin polls the `endpoints` URL from your config. With `createUpdaterArtifacts` enabled, each build emits the bundle plus a detached `.sig` signature; you host those alongside a small JSON manifest that lists the current version, per-platform download URLs, and the matching signatures. The client verifies the signature against the `pubkey` baked into the app before it installs, so a compromised download host cannot push a malicious update. The cheapest place to host the manifest is the GitHub Release itself, or any static bucket behind a CDN.

latest.json (the update manifest)
{
  "version": "1.4.0",
  "notes": "Reconnect on dropped tunnels; fix tray menu on Wayland.",
  "pub_date": "2026-04-22T10:00:00Z",
  "platforms": {
    "windows-x86_64": {
      "signature": "dW50cnVzdGVkIGNvbW1lbnQ6...",
      "url": "https://github.com/you/sshpilot/releases/download/v1.4.0/sshpilot_1.4.0_x64-setup.nsis.zip"
    },
    "darwin-aarch64": {
      "signature": "dW50cnVzdGVkIGNvbW1lbnQ6...",
      "url": "https://github.com/you/sshpilot/releases/download/v1.4.0/sshpilot_aarch64.app.tar.gz"
    },
    "linux-x86_64": {
      "signature": "dW50cnVzdGVkIGNvbW1lbnQ6...",
      "url": "https://github.com/you/sshpilot/releases/download/v1.4.0/sshpilot_1.4.0_amd64.AppImage.tar.gz"
    }
  }
}

The signature here is the contents of the `.sig` file the bundler produced, not a hash you compute yourself. Generate the `pubkey` / private key pair with `tauri signer generate`, keep the private key in CI secrets as `TAURI_SIGNING_PRIVATE_KEY`, and never rotate it casually — clients already in the wild trust only the key that shipped with them. The full schema and the platform-key naming are documented at the Tauri updater plugin docs.

What is the shortest path to shipping all three?

Stripped to essentials, cross-platform Tauri packaging is three formats, two signing chores, and one workflow file. Each item below maps to a concrete failure I have hit and fixed.

  • List every target you want in `bundle.targets`, but accept that each OS only emits its own — the matrix is what fulfils the list.
  • Declare `linux.deb.depends` for `libwebkit2gtk-4.1-0`, or the `.deb` installs and then refuses to launch.
  • Sign Windows with an Authenticode cert via `WINDOWS_CERTIFICATE` (EV for instant SmartScreen reputation), or your users get the scary full-screen warning.
  • Sign AND notarize macOS — a `Developer ID` signature without an Apple notarization ticket still gets quarantined by Gatekeeper.
  • Keep the updater's `TAURI_SIGNING_PRIVATE_KEY` separate from your OS code-signing certificates, set `createUpdaterArtifacts: true`, host `latest.json` plus the `.sig` files, and guard the signing key like a deploy secret.

Once this is wired up, releasing a new version is just pushing a `v1.4.1` tag and approving the draft release GitHub Actions builds for you — three signed installers, an updater manifest, no manual machine wrangling. If you are still deciding whether Tauri is worth this setup over the alternative, I made that case in why I reach for Tauri instead of Electron; and if you want a real app to package this way, the offline SSH client I built in Rust and xterm.js ships through exactly this pipeline. The hard part of cross-platform desktop was never the code — it was the packaging, and a CI matrix is what makes the packaging boring.