Best Practices for Sidecar Binaries
Production-tested best practices for embedding, securing, and maintaining external binaries as sidecars in Tauri v2 desktop applications
Sidecars give your Tauri application access to entire ecosystems of existing tools — Python scripts, compiled CLI utilities, database engines, FFmpeg, you name it. But bundling an external process inside a desktop app introduces challenges around cross-platform builds, security, binary size, and lifecycle management that do not exist when you are just running a command on your own machine. On macOS, sidecars inside the .app also need code signing.
The practices on this page come from real Tauri projects that ship sidecars to users on multiple platforms. They address the recurring points of failure that developers hit after the "hello world" sidecar works in dev but breaks in production.
Cross-Platform Binary Naming
Tauri uses a filename convention to pick the correct binary for the user's operating system and CPU architecture. A sidecar declared as "binaries/my-tool" in tauri.conf.json must actually exist on disk as my-tool-$TARGET_TRIPLE (plus .exe on Windows). At runtime, Tauri strips the suffix and loads the right file.
The target triple is a string that identifies the platform. You can see your own by running:
rustc --print host-tuple
Common triples you will encounter in desktop applications:
| Platform | Triple suffix | Binary name example |
|---|---|---|
| macOS Apple Silicon | aarch64-apple-darwin | my-tool-aarch64-apple-darwin |
| macOS Intel | x86_64-apple-darwin | my-tool-x86_64-apple-darwin |
| Windows x64 | x86_64-pc-windows-msvc | my-tool-x86_64-pc-windows-msvc.exe |
| Linux x64 | x86_64-unknown-linux-gnu | my-tool-x86_64-unknown-linux-gnu |
A sidecar project that supports all four platforms therefore ships four separate binary files, all inside src-tauri/binaries/ (or whichever relative path you configured).
The naming must be exact. A single character off — x86_64-unknown-linux-musl versus x86_64-unknown-linux-gnu — will cause Tauri to fail to find the sidecar at runtime. Use rustc --print host-tuple on the actual build machine, not a generic list from the internet.
A small build script that renames a freshly-compiled binary is the simplest way to avoid manual naming mistakes. Here is a Node.js script that renames a binary called sidecar to include the host triple:
// scripts/rename-sidecar.mjs
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
const extension = process.platform === 'win32' ? '.exe' : '';
const binaryDir = path.resolve('src-tauri/binaries');
const sourceName = path.join(binaryDir, `sidecar${extension}`);
const targetTriple = execSync('rustc --print host-tuple').toString().trim();
if (!targetTriple) {
console.error('Failed to determine platform target triple');
process.exit(1);
}
const targetName = path.join(binaryDir, `sidecar-${targetTriple}${extension}`);
fs.renameSync(sourceName, targetName);
console.log(`Renamed ${sourceName} → ${targetName}`);
Run this script on each build machine after compiling the sidecar for that platform. It removes the guesswork entirely.
Cross-compilation edge cases:
This script only names the binary correctly for the machine it runs on. If you are cross-compiling from a single CI runner to multiple targets, you need to set the target triple explicitly from your build matrix rather than reading rustc --print host-tuple. Use environment variables or build tooling to pass the intended triple.
Binary Size Optimization
External binaries shipped as sidecars are not compiled by Tauri's Rust build pipeline, so none of your Cargo.toml release profile settings apply to them. Their size is entirely your responsibility.
Size matters for sidecars because they are part of the application download. A 120 MB Python sidecar bundled with PyInstaller can triple your installer size. Users on slow connections or metered data will notice.
The most effective techniques depend on how the sidecar is built.
For Rust sidecars — apply the same optimizations you would for any Rust release binary. In the sidecar's own Cargo.toml:
[profile.release]
opt-level = "z" # Optimize for size
lto = "fat" # Link-time optimization across all crates
codegen-units = 1 # Single codegen unit for better optimization
strip = true # Strip debug symbols
panic = "abort" # Remove panic unwinding machinery
After building, you can further reduce size with upx --best (UPX is a tool that compresses executables and decompresses them in memory at runtime). This is safe for most CLI binaries. Test thoroughly — some antivirus software flags UPX-packed executables, and on macOS, UPX-compressed binaries may not be notarizable.
For Python sidecars — use PyInstaller with the --onefile flag to produce a single self-contained binary. This is non-negotiable for distribution; multi-file PyInstaller builds will fail when Tauri tries to resolve them. Include only the dependencies the script actually needs, and strip out unused standard library components. The --exclude-module flag can remove large modules you do not use (e.g., tkinter, unittest, test).
pyinstaller --onefile --name my-sidecar-aarch64-apple-darwin my_script.py
If your Python sidecar depends on large libraries like pandas or numpy, consider whether you can achieve the same result with a compiled language or a Rust crate. Several Tauri projects start with a Python sidecar for rapid prototyping, then migrate to a Rust sidecar or a tauri::command once the logic stabilizes.
Porting Python to Rust over time:
Using a Python sidecar early in development is a valid strategy. It lets you ship functionality fast while you build a Rust replacement in parallel. The sidecar interface stays the same — the frontend does not care what language the binary is written in.
For any compiled binary — strip debug symbols. For C/C++ binaries, pass -s to the linker or run strip on the output. For Go binaries, build with -ldflags="-s -w". Every megabyte matters when you are shipping four copies of the same tool for four platforms.
Security Considerations
An external binary runs with the same privileges as your Tauri application. It can read and write files, make network requests, spawn child processes, and interact with the operating system. If the sidecar is compromised — or if a malicious input reaches it through your frontend — the blast radius is the same as your own Rust code.
The Tauri v2 capability system is the primary defense. Sidecar execution is gated by explicit shell permissions. Never grant more than the sidecar actually needs.
Start with a capability file that names the sidecar explicitly and limits its arguments:
// src-tauri/capabilities/default.json
{
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
{
"identifier": "shell:allow-execute",
"allow": [
{
"name": "binaries/my-tool",
"sidecar": true,
"args": [
"process",
{
"validator": "\\S+"
}
]
}
]
}
]
}
This configuration does three things. It allows only the binary at binaries/my-tool to be executed as a sidecar. It requires exactly two arguments: the static string "process" and one dynamic argument that must be non-whitespace. Any other combination of arguments — including zero arguments or three arguments — will be rejected before the sidecar is spawned.
The wildcard args trap:
Setting "args": true in the capability allows any arguments to be passed, including arguments injected by a compromised frontend dependency. This is equivalent to running a shell command with arbitrary user input. Prefer explicit argument lists with validators. If you genuinely need free-form arguments, you must sanitize them in Rust before spawning.
A strict Content Security Policy prevents injected scripts in the webview from calling sidecar APIs in the first place. In tauri.conf.json:
{
"app": {
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
}
}
}
This CSP blocks any script that was not bundled with your application, which means a cross-site scripting attack cannot reach the Tauri shell plugin at all.
For distribution, both your main application and the sidecar binary must be code-signed. On macOS, an unsigned sidecar will trigger Gatekeeper and prevent your app from launching. On Windows, an unsigned CLI tool that opens network connections is likely to be flagged by antivirus heuristics.
# Sign a sidecar on macOS
codesign --force --sign "Developer ID Application: Your Name" \
src-tauri/binaries/my-tool-aarch64-apple-darwin
Root privileges are not a sidecar feature:
A non-root process cannot spawn a child process that runs as root, and a Tauri app should not run as root in the first place. If your sidecar genuinely requires elevated privileges — a rare case, like a system-level network tool — you must design around this at the OS level (setuid binaries, helper tools, privileged daemons). Asking users to sudo your entire desktop app is not a production solution. See Tauri issue #5274 for the community discussion.
Version Management
A sidecar is a separate piece of software with its own release cycle. Its version and your application's version drift apart over time unless you manage them together deliberately.
For sidecars that change rarely — a compiled CLI utility, a small database engine — bundle the binary directly in the repository. This keeps a known-good version locked to each application release. Your CI pipeline builds or downloads the correct sidecar version as part of the application build, and the sidecar is never updated independently.
For sidecars that need to update on their own (like a CLI tool that receives frequent security patches), implement a self-update mechanism from your Rust backend. The pattern involves three steps: download the new binary, verify its integrity, and atomically replace the old one.
Here is a Rust function that performs an atomic sidecar update, storing the binary in the app's config directory so it can be written to at runtime:
// src-tauri/src/sidecar_update.rs
use std::path::PathBuf;
use std::fs;
use tauri::AppHandle;
pub async fn update_sidecar(
app: &AppHandle,
download_url: &str,
expected_sha256: &str,
) -> Result<(), String> {
let config_dir = app.path()
.app_config_dir()
.map_err(|e| format!("Config dir error: {}", e))?;
let final_path = config_dir.join("my-tool");
let tmp_path = config_dir.join("my-tool.downloading");
let old_path = config_dir.join("my-tool.old");
// 1. Download the new binary to a temp location
let response = reqwest::get(download_url)
.await
.map_err(|e| format!("Download failed: {}", e))?;
let bytes = response.bytes()
.await
.map_err(|e| format!("Read response failed: {}", e))?;
fs::write(&tmp_path, &bytes)
.map_err(|e| format!("Write temp file failed: {}", e))?;
// 2. Verify checksum
let hash = sha256::digest(fs::read(&tmp_path)
.map_err(|e| format!("Read temp file for hash: {}", e))?);
if hash != expected_sha256 {
fs::remove_file(&tmp_path).ok();
return Err("Checksum mismatch".to_string());
}
// 3. Atomic swap: rename old → .old, new → target
if final_path.exists() {
fs::rename(&final_path, &old_path).ok(); // best-effort backup
}
fs::rename(&tmp_path, &final_path)
.map_err(|e| format!("Atomic rename failed: {}", e))?;
// 4. Clean up old backup
fs::remove_file(&old_path).ok();
Ok(())
}
The atomic swap — writing to a temp file, then renaming — guarantees that the sidecar path never contains a partially-written binary. If the application crashes during download, the temp file is left behind and can be cleaned up on next launch. If the new binary fails to start, the old one is still available as .old for manual recovery.
Requires reqwest and sha256 crates:
The example uses reqwest for HTTP and a sha256 crate for checksums. Add them to src-tauri/Cargo.toml. Enable the reqwest/native-tls feature for TLS support on all platforms.
Never download a sidecar over plain HTTP. Always use HTTPS and pin the expected checksum. Without checksum verification, an attacker who compromises the download server can replace the sidecar with malware that runs with your application's privileges.
Error Handling and Process Lifecycle
A sidecar process is a separate operating system entity. It can crash, hang, produce garbled output, or exit silently. Your application must handle all of these states gracefully.
The most common mistake is trusting that spawn() returning Ok means the sidecar is healthy. It does not. It means the operating system started the process. The binary might segfault 200 milliseconds later, fail to bind a port, or hang waiting for input that never comes. You need to observe real evidence of correct behavior.
Store the CommandChild handle returned by spawn(). You need it to send input, check the process status, and kill it when the application shuts down.
// src-tauri/src/commands.rs
use tauri::AppHandle;
use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use std::sync::Mutex;
#[tauri::command]
async fn start_sidecar(
app: AppHandle,
state: tauri::State<'_, Mutex<Option<CommandChild>>>,
) -> Result<(), String> {
let (mut rx, child) = app.shell()
.sidecar("my-tool")
.map_err(|e| e.to_string())?
.spawn()
.map_err(|e| format!("Spawn failed: {}", e))?;
// Keep the child handle so we can stop it later
*state.lock().unwrap() = Some(child);
// Process output in a background task
tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(bytes) => {
let line = String::from_utf8_lossy(&bytes);
println!("[sidecar] {}", line);
app.emit("sidecar-stdout", line.to_string())
.expect("emit failed");
}
CommandEvent::Stderr(bytes) => {
let line = String::from_utf8_lossy(&bytes);
eprintln!("[sidecar err] {}", line);
app.emit("sidecar-stderr", line.to_string())
.expect("emit failed");
}
CommandEvent::Terminated(status) => {
app.emit("sidecar-terminated", status.code)
.expect("emit failed");
break;
}
_ => {}
}
}
});
Ok(())
}
In your frontend, use the sidecar-terminated event to update the UI when the process exits. Do not assume the process is still alive just because you started it 30 seconds ago.
For long-running sidecars like local servers or database daemons, implement a health check. After spawning, poll the sidecar's endpoint or check for a ready signal in its stdout. An exponential backoff — retry at 1 second, then 2, then 4, up to a maximum — avoids hammering a process that is still initializing while still detecting failures promptly.
// Health-check loop with exponential backoff
let mut delay = 1u64;
let max_delay = 16;
loop {
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
if sidecar_is_healthy().await {
break; // Ready
}
delay = (delay * 2).min(max_delay);
}
On application shutdown, kill the sidecar cleanly. The CommandChild can be killed with .kill(), or you can send a graceful shutdown signal if the sidecar supports it. Tauri's on_exit hook in the builder is a good place to clean up:
// src-tauri/src/lib.rs
tauri::Builder::default()
.on_exit(|app| {
if let Some(child) = app.state::<Mutex<Option<CommandChild>>>().lock().unwrap().take() {
let _ = child.kill();
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
Common Mistakes
This section collects the errors that appear repeatedly in Tauri sidecar projects. If you are debugging a problem, check here first.
Sidecar not found at runtime:
The most frequent cause is an incorrect target triple suffix. Tauri logs will show the exact path it tried to load. Compare that path with the actual filename in src-tauri/binaries/. A trailing .exe on Windows, a dash versus underscore, or a different libc variant (musl vs gnu) are all breaking differences.
Including the platform suffix in tauri.conf.json. The externalBin array should contain the base name: "binaries/my-tool", not "binaries/my-tool-x86_64-unknown-linux-gnu". Tauri appends the target triple automatically.
Forgetting to add shell permissions. Without the shell:allow-execute or shell:allow-spawn permission in your capability file, the sidecar call will fail with a permissions error. This is the second most common reason a sidecar works in tauri dev but not in a production build — dev mode often has looser defaults.
Shipping a PyInstaller sidecar without --onefile. A multi-file PyInstaller build produces a directory with an internal _internal folder. When Tauri tries to run the binary, Python's shared library is not at the expected relative path, and you get the "Failed to load Python shared library" error. Always use --onefile and verify the binary runs standalone before bundling it.
Not handling sidecar process exit on app close. If your Tauri app shuts down but the sidecar keeps running, the user will see a lingering process in Task Manager or Activity Monitor. Always kill sidecars in the on_exit hook.
Using Command.sidecar() on the frontend without arguments validation. If the capability file allows arguments and the frontend passes unsanitized user input directly, a specially crafted input could inject additional flags or commands. Always validate and sanitize inputs in the Rust layer before spawning.
Summary
Sidecars are the bridge between Tauri's sandboxed web frontend and the full power of native executables. Getting them right means thinking about them as first-class parts of your application, not as an afterthought.
The central insight is that a sidecar is an independent program with its own lifecycle, security surface, and platform requirements. The practices that work for a shell script you run manually do not transfer to a binary embedded in a cross-platform desktop app. You must name files precisely, lock down permissions explicitly, manage the process lifecycle defensively, and plan for updates before the first user reports a broken sidecar on a platform you never tested.
If you are building your first sidecar, start with these three non-negotiables: name the binary with the exact target triple, grant only the minimum shell permissions with argument validators, and store the CommandChild handle so you can shut the process down cleanly. The rest — size optimization, health checks, self-updating — can be added incrementally as your application matures.