Sidecar Pattern
Bundling and managing Node.js or other binaries as Tauri sidecar processes
Sidecar Pattern
The sidecar pattern bundles a standalone binary (typically Node.js) inside the Tauri .app bundle. This makes the app self-contained — it does not depend on tools installed on the user’s system.
Why Not Use Shell Commands?
The most common first attempt is to shell out to node or pnpm:
// This BREAKS when launched from Finder
Command::new("pnpm").args(["dev"]).spawn();
⚠️ Warning
This approach fails in production because macOS Finder launches apps with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). Your Homebrew-installed pnpm, nvm-managed node, and other developer tools are not on this PATH. The command simply fails with “No such file or directory”.
There are two solutions:
- Find the binary at known absolute paths — simpler but requires the tool to be installed
- Bundle the binary inside the app — fully self-contained, no system dependencies
This page covers the bundled approach (option 2). For the absolute-path approach, see the find_pnpm() pattern in Dev vs Production.
Step 1: Download the Binary
Create a download script that fetches the Node.js standalone binary for the target platform with checksum verification:
#!/usr/bin/env bash
# scripts/download-node.sh
# Download Node.js standalone binary for Tauri sidecar bundling.
set -euo pipefail
NODE_VERSION="v24.13.0"
ARCH="aarch64"
PLATFORM="darwin"
TARGET_TRIPLE="${ARCH}-apple-${PLATFORM}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
BINARIES_DIR="${APP_DIR}/binaries"
OUTPUT="${BINARIES_DIR}/node-${TARGET_TRIPLE}"
URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-darwin-arm64.tar.gz"
EXPECTED_SHA256="d595961e563fcae057d4a0fb992f175a54d97fcc4a14dc2d474d92ddeea3b9f8"
if [[ -f "${OUTPUT}" ]]; then
echo "Node binary already exists at ${OUTPUT}"
echo "Delete it first if you want to re-download."
exit 0
fi
mkdir -p "${BINARIES_DIR}"
TMPDIR_DL="$(mktemp -d)"
trap 'rm -rf "${TMPDIR_DL}"' EXIT
echo "Downloading Node.js ${NODE_VERSION} for macOS arm64..."
curl -fSL --progress-bar "${URL}" -o "${TMPDIR_DL}/node.tar.gz"
echo "Verifying checksum..."
ACTUAL_SHA256="$(shasum -a 256 "${TMPDIR_DL}/node.tar.gz" | cut -d ' ' -f 1)"
if [[ "${ACTUAL_SHA256}" != "${EXPECTED_SHA256}" ]]; then
echo "ERROR: SHA256 mismatch!"
echo " Expected: ${EXPECTED_SHA256}"
echo " Actual: ${ACTUAL_SHA256}"
exit 1
fi
echo "Checksum OK."
echo "Extracting binary..."
tar -xzf "${TMPDIR_DL}/node.tar.gz" -C "${TMPDIR_DL}" \
--strip-components=2 "node-${NODE_VERSION}-darwin-arm64/bin/node"
mv "${TMPDIR_DL}/node" "${OUTPUT}"
chmod +x "${OUTPUT}"
echo "Done: ${OUTPUT}"
echo "Size: $(du -h "${OUTPUT}" | cut -f1)"
Key aspects of this script:
- SHA256 verification prevents supply-chain attacks and ensures reproducible builds
- Idempotent — if the binary already exists, it exits cleanly
- Target triple naming — the output file is named
node-aarch64-apple-darwin, which is the convention Tauri expects
💡 Tip
Always verify the checksum. Get the expected SHA256 from the official Node.js release page (SHASUMS256.txt) for the specific version you are downloading.
Step 2: Configure externalBin
In tauri.conf.json, declare the binary path (without the target triple suffix):
{
"bundle": {
"externalBin": ["binaries/node"]
}
}
Tauri automatically appends the target triple during the build process. So binaries/node becomes:
binaries/node-aarch64-apple-darwinon Apple Siliconbinaries/node-x86_64-apple-darwinon Intel Mac
The binary file in your binaries/ directory must be named with the full target triple.
Step 3: Binary Path Resolution in Rust
At runtime, you need to find the bundled binary. The path depends on whether you are in dev or production mode:
fn node_binary_path() -> std::path::PathBuf {
let exe = std::env::current_exe().expect("Failed to get current exe path");
let dir = exe.parent().expect("Failed to get exe directory");
// Dev mode: Tauri keeps the target triple in the filename
let target_triple = format!("{}-apple-darwin", std::env::consts::ARCH);
let dev_path = dir.join(format!("node-{}", target_triple));
if dev_path.exists() {
return dev_path;
}
// Production bundle: Tauri strips the target triple
dir.join("node")
}
⚠️ Warning
This is a critical detail that is easy to miss: Tauri strips the target triple from the binary name in the production bundle. During development, the binary is at node-aarch64-apple-darwin. In the production .app bundle, it is just node. Your code must handle both cases.
Why the Path Differs
During cargo tauri dev, the binary is copied to the same directory as your compiled Rust binary (inside target/debug/), and it keeps its full name including the target triple.
When you run cargo tauri build, Tauri bundles the binary inside Contents/MacOS/ of the .app bundle, and it strips the target triple suffix.
# Dev mode directory (target/debug/)
node-aarch64-apple-darwin <-- full name with triple
# Production bundle (MyApp.app/Contents/MacOS/)
node <-- triple stripped
my-app <-- your app binary
Step 4: Check Binary Existence
Always verify the binary exists before attempting to spawn it. A clear error message saves hours of debugging:
fn spawn_sidecar() -> Sidecar {
let node = node_binary_path();
if !node.exists() {
let msg = format!(
"Node binary not found at {}. Run scripts/download-node.sh first.",
node.display()
);
log(&msg);
panic!("{msg}");
}
let mut cmd = Command::new(&node);
cmd.args(["scripts/dev-stable.js"])
.current_dir(&dir)
.stdout(Stdio::from(log_file))
.stderr(Stdio::from(log_file_clone));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let child = cmd.spawn().expect("Failed to spawn node sidecar");
let pid = child.id();
Sidecar { child, pid }
}
Step 5: .gitignore the Binaries
The downloaded binary is large (typically 80-100 MB for Node.js) and platform-specific. Never commit it:
/binaries/
Instead, document the download step in your README or setup script. The download script is the source of truth for which binary version to use.
Complete File Layout
my-app/
Cargo.toml
tauri.conf.json # externalBin: ["binaries/node"]
binaries/
node-aarch64-apple-darwin # Downloaded binary (gitignored)
scripts/
download-node.sh # Committed download script
src/
main.rs # node_binary_path() + spawn logic
frontend/
index.html # Loading page
.gitignore # Includes /binaries/
System Dependency Alternative
If you can assume your users have the right tools installed (common for developer tools), you can skip the sidecar bundling and find the binary at known system paths instead:
fn find_pnpm() -> Option<PathBuf> {
let candidates = [
"/opt/homebrew/bin/pnpm", // Apple Silicon Homebrew
"/usr/local/bin/pnpm", // Intel Homebrew
];
for p in &candidates {
let path = PathBuf::from(p);
if path.exists() {
return Some(path);
}
}
None
}
This is simpler but ties the app to the user’s system configuration. The bundled sidecar approach is preferred for distribution.