Part of Tauri Patterns for Production

Tauri Patterns for Production: Ship Tauri 2 to Mac, Windows & Linux

Celest KimCelest Kim

Video: Ship Tauri 2 to Mac, Windows & Linux | GitHub Actions Matrix Tutorial by CelesteAI

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

A Tauri app starts life on whatever OS you happen to develop on. Shipping it to the other two is where most projects stall — cross-compiling Rust GUI binaries from a single laptop is painful, native deps differ wildly, and the bundle targets (.dmg, .msi/.exe, .deb/.AppImage) all want their own platform-specific tooling. The mature answer is to let GitHub Actions do the work: one tag push, three runners in parallel, three installers attached to a release.

This tutorial builds TriShip, a minimal Tauri 2 app that does nothing but show its host OS and architecture, and then sets up a .github/workflows/release.yml that produces a .dmg, a Windows .exe installer, and a Linux .AppImage from a single git tag push. Every line of the workflow is explained. The pattern works for any Tauri 2 app — the workflow is the deliverable, the app is window dressing.


What we’re actually building

The shape:

.github/workflows/release.yml   ← the multi-platform build pipeline
src-tauri/tauri.conf.json       ← bundle.targets includes dmg, nsis, deb, appimage
src/App.tsx                     ← UI that reads host_info from Rust
src-tauri/src/lib.rs            ← #[tauri::command] returning OS + arch

A git tag v0.1.0 && git push origin v0.1.0 triggers the workflow. Three jobs spin up on macos-latest, windows-latest, and ubuntu-22.04. Each one installs Rust, installs pnpm, runs pnpm tauri build --target <triple>, and uploads the resulting installer to a GitHub Release named v0.1.0. Total wall-clock time is about 8-12 minutes depending on cache warmth.

The end state is a Releases page on your repo with three downloadable installers. Anyone with a Mac, a Windows machine, or a Linux box can install TriShip in the way native to their platform — and from your build machine you never touched anything but git tag.


Step 1 — Configure bundle targets in tauri.conf.json

The first thing the workflow needs is for tauri build to actually produce installers for each platform. That’s controlled by the bundle.targets array:

"bundle": {
  "active": true,
  "targets": ["dmg", "nsis", "deb", "appimage"],
  "icon": [...]
}

Each target is platform-specific:

  • dmg — macOS disk image installer. Produced only on macOS runners.
  • nsis — Windows installer using the NSIS scriptable installer. Produces TriShip_0.1.0_x64-setup.exe. Only runs on Windows.
  • deb — Debian package for Ubuntu/Debian. Only on Linux.
  • appimage — Self-contained Linux binary that runs on most distros. Only on Linux.

tauri build is smart enough to skip targets that don’t apply to the current host. So on macOS it produces the .dmg and ignores nsis/deb/appimage. On Linux it builds deb and appimage. On Windows it builds the nsis installer. Three runners, three subsets of targets, no conditionals needed in your config.

You can also include "app" (a .app bundle without the DMG wrapper) and "updater" (a .app.tar.gz for the updater plugin). For initial distribution, the four above are enough.


Step 2 — The workflow file

The whole pipeline fits in 35 lines:

name: Release

on:
  push:
    tags: ["v*"]

permissions:
  contents: write

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        platform:
          - { os: macos-latest,   target: aarch64-apple-darwin,        artifact: "*.dmg" }
          - { os: windows-latest, target: x86_64-pc-windows-msvc,      artifact: "*-setup.exe" }
          - { os: ubuntu-22.04,   target: x86_64-unknown-linux-gnu,    artifact: "*.AppImage" }

    runs-on: ${{ matrix.platform.os }}

    steps:
      - uses: actions/checkout@v4

      - name: Install Linux deps
        if: matrix.platform.os == 'ubuntu-22.04'
        run: |
          sudo apt-get update
          sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf

      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.platform.target }}

      - uses: pnpm/action-setup@v4
        with: { version: 10 }

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Build Tauri bundle
        run: pnpm tauri build --target ${{ matrix.platform.target }}

      - uses: softprops/action-gh-release@v2
        with:
          files: src-tauri/target/${{ matrix.platform.target }}/release/bundle/**/${{ matrix.platform.artifact }}

There are six things happening, in order:

2a. The trigger

on:
  push:
    tags: ["v*"]

The workflow runs only when a tag starting with v is pushed. v0.1.0, v1.2.3, v2.0.0-rc.1 — all trigger it. A regular git push to main does not. This separates “development” pushes from “release” pushes cleanly, and means you never accidentally publish a Release.

2b. The permissions

permissions:
  contents: write

This grants the workflow’s auto-issued GITHUB_TOKEN permission to create a GitHub Release. Without it, the action-gh-release step fails with a 403. It’s a per-workflow opt-in because the default token is read-only — a sensible security default.

2c. The matrix

strategy:
  fail-fast: false
  matrix:
    platform:
      - { os: macos-latest, target: aarch64-apple-darwin, artifact: "*.dmg" }
      - { os: windows-latest, target: x86_64-pc-windows-msvc, artifact: "*-setup.exe" }
      - { os: ubuntu-22.04, target: x86_64-unknown-linux-gnu, artifact: "*.AppImage" }

This is the heart of the pipeline. strategy.matrix.platform is a list of objects; for each entry, GitHub spawns a separate runner. The three jobs run in parallel, not sequentially — total wall time is the slowest job, not the sum of all three. fail-fast: false means if Windows fails, macOS and Linux still complete. Without it, the first failure cancels the others, which makes triage hard.

The three keys in each entry:

  • os — which GitHub-hosted runner to use. macos-latest, windows-latest, and ubuntu-22.04 are the standard set. ubuntu-latest aliases to ubuntu-24.04 as of late 2024, but 24.04 ships libwebkit2gtk-4.1 only, not 4.0. Pinning to ubuntu-22.04 saves you from “tauri v2 wants 4.1 but build script expects 4.0” issues on edge configs. Once your dependencies catch up, move to ubuntu-latest.
  • target — the Rust target triple. tauri build --target $T cross-compiles to that target. For first-party platforms you don’t strictly need --target on the host’s native arch, but explicit is better than implicit, and it lets us share the artifact-glob pattern.
  • artifact — a glob matching the installer file path under target/release/bundle/. Each bundler nests differently (DMGs in bundle/dmg/, NSIS in bundle/nsis/, AppImages in bundle/appimage/), so we use bundle/**/{glob} later to find them.

2d. The runner

runs-on: ${{ matrix.platform.os }}

This picks up the os from the matrix entry. Three jobs, three runners — each on the right OS for its bundle target.

2e. The steps

Each runner does the same six things, with one platform-specific exception:

  1. Checkout the repoactions/checkout@v4. Standard.
  2. Linux-only: install native depswebkit2gtk, appindicator, librsvg, patchelf. These are what Tauri uses to render the webview, manage the system tray, render SVG icons, and patch the AppImage binary. macOS and Windows have their equivalents built into the OS, so no install step needed there. The if: condition keeps this step from running on the other two runners.
  3. Install Rustdtolnay/rust-toolchain@stable with targets: ${{ matrix.platform.target }} so the right target’s stdlib is downloaded. This action is fast and idempotent; it’ll skip if Rust is already cached on the runner image.
  4. Install pnpmpnpm/action-setup@v4. Same pattern.
  5. Install Node + cacheactions/setup-node@v4 with cache: pnpm so subsequent runs reuse the package cache. First run takes longer; subsequent runs save 1-2 minutes per platform.
  6. Install JS deps + buildpnpm install --frozen-lockfile then pnpm tauri build --target ${{ matrix.platform.target }}. The --frozen-lockfile flag refuses to update the lockfile, which is what you want in CI — your builds should be reproducible.

2f. The upload

- uses: softprops/action-gh-release@v2
  with:
    files: src-tauri/target/${{ matrix.platform.target }}/release/bundle/**/${{ matrix.platform.artifact }}

softprops/action-gh-release looks at the tag that triggered the workflow (here, v0.1.0), creates a Release with that tag’s name if one doesn’t exist, and uploads the matched files to it. The glob walks into the platform-specific subdirectory of bundle/ and picks up just the installer file.

Because all three jobs upload to the same release (matched by tag name), the end result is one Release with three attachments. The action handles concurrent uploads correctly — order doesn’t matter.


Step 3 — Trigger the workflow

With the workflow committed and pushed, all that’s left is to cut a release:

git tag v0.1.0
git push origin v0.1.0

That’s it. Refresh the repo’s Actions tab and you’ll see three jobs running. Refresh the Releases tab a few minutes later (once jobs finish) and you’ll see one Release with three downloadable installers.

For version bumps, bump version in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json, commit, then tag:

git tag v0.1.1
git push origin v0.1.1

And the next Release is built. The whole “ship a new version” loop is three lines of shell.


Common failures and how to fix them

A few things bite the first time:

“Permission denied” on the release step

The workflow’s GITHUB_TOKEN doesn’t have contents: write by default. Add the permissions: block at the top of the workflow file. If you’ve also locked down the repo’s “Workflow permissions” setting (Settings → Actions → General), make sure “Read and write permissions” is selected.

Linux build fails with “no package ‘webkit2gtk-4.1’ found”

The apt install line is missing or the runner is older than 22.04. Tauri v2 moved to webkit2gtk-4.1; older runners only have 4.0. Pin to ubuntu-22.04 and include libwebkit2gtk-4.1-dev in the install line.

Windows build hangs on “Initializing…”

Usually a Rust toolchain mismatch. The dtolnay/rust-toolchain@stable action picks the latest stable, which sometimes lags behind on Windows. Pin to a specific version (@1.78.0) if you hit this; rerun on the same Rust version that works on your local machine.

macOS build succeeds but the .dmg won’t open (“damaged”)

This is Gatekeeper. The bundle is unsigned, so macOS refuses to launch it without ceremony. Users can right-click → Open the first time, or run xattr -cr /Applications/TriShip.app after copying it over. For production, you’d add an Apple Developer ID code-signing step before the upload — outside the scope of this tutorial, but the certificate gets injected via repo secrets and the workflow uses codesign on the produced .app.

Linux AppImage runs but UI is blank

This is almost always a libwebkit2gtk runtime issue. On the user’s machine, libwebkit2gtk-4.1-0 must be installed. For maximum portability, ship the .deb (which declares the dep) alongside the AppImage and let users pick.


What this workflow does NOT do

This is a starter pipeline, not a production one. The next things you’d want to add:

  • Code signing. Apple Developer ID for macOS, Authenticode cert for Windows, GPG for the .deb. Without these, users see scary OS warnings. Each platform has a different signing flow; the workflow needs secrets and a few extra steps per runner.
  • Notarization. macOS additionally requires uploading the signed .app to Apple’s notarization service, which scans for malware and stamps it as “Apple-approved.” Adds ~5 min to the macOS job. Use tauri-action’s built-in support or call xcrun notarytool directly.
  • Per-architecture builds. This workflow builds ARM64 macOS, x64 Windows, x64 Linux. To also ship Intel Macs and ARM Linux, add more matrix entries. Each one is a separate parallel job.
  • Smaller artifacts. Release builds without optimization can be 15-30 MB. Add [profile.release] settings in Cargo.toml (lto = true, strip = true, panic = "abort") to shrink to 8-12 MB. This costs an extra ~30s of build time but produces installers users actually want to download.
  • Auto-update integration. If you wired up tauri-plugin-updater in a previous episode, the workflow can also produce signed .app.tar.gz + .sig files and upload them to the same release — your updater endpoint reads the release metadata to find new versions. Add "updater" to bundle.targets and set the signing key as a repo secret.

Each one is a few lines. Get the matrix-build skeleton working first; layer them on once you ship the first installer.


Why this works

Three things made this whole pipeline tractable:

GitHub-hosted runners give you a working build environment for free. macOS, Windows, and Linux — all three with reasonable defaults, all three usable without you owning the hardware. Self-hosted runners are an option if you need specific hardware (M-series Macs for iOS builds, beefier Linux for big projects), but for a normal Tauri app the hosted runners are plenty.

Matrix strategy collapses three workflows into one. The 35 lines above would otherwise be ~90 lines spread across three separate workflow files. Matrix gives you DRY without sacrificing readability.

action-gh-release handles the messy parts of the GitHub Releases API. Tag matching, idempotent release creation, concurrent uploads, hash digests, draft mode if you want a manual review step — all handled. You point it at the file and it figures out the rest.

The pattern is the same for any binary you want to ship cross-platform — substitute the bundle target for whatever produces your binaries, leave everything else identical, and you have a release pipeline.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.