This guide provides a comprehensive walkthrough on how to package a Phoenix LiveView application as a native desktop app for macOS, Windows, and Linux using Tauri. This approach bundles your compiled Phoenix release with the Tauri application, creating a single, self-contained executable that runs entirely locally.
Prerequisites
To follow this guide, you will need:
- Elixir 1.15+ with Phoenix 1.8+
- Rust and Cargo (for Tauri's core functionality)
- Node.js (for asset building and Tauri CLI)
- A development environment for your target OS (e.g., Xcode for macOS, Visual Studio Build Tools for Windows).
Step 1: Create Your Phoenix Project
We will start by creating a new Phoenix project. Using SQLite is highly recommended for desktop applications as it is an embedded database that doesn't require a separate server.
mix phx.new todo_app --database sqlite3 cd todo_app Step 2: Configure Phoenix for Desktop Deployment
The Phoenix application needs specific configuration adjustments to run as a sidecar process within a Tauri bundle.
Update config/runtime.exs
We will modify the production configuration block to handle the desktop environment variables set by Tauri.
if config_env() == :prod do # --- Desktop Application Configuration --- # 1. Database Path: Set by Tauri to point to the app's data directory database_path = System.get_env("DATABASE_PATH") || raise """ environment variable DATABASE_PATH is missing. For desktop app, this should be set by the Tauri launcher. Example: ~/Library/Application Support/TodoApp/todo_app.db """ config :todo_app, TodoApp.Repo, database: database_path, # Keep pool size small for embedded SQLite to avoid SQLITE_BUSY errors pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5") # 2. Secret Key Base: Generated at runtime for local desktop app secret_key_base = System.get_env("SECRET_KEY_BASE") || :crypto.strong_rand_bytes(64) |> Base.encode64(padding: false) |> binary_part(0, 64) # 3. Fixed Port: Use a fixed port (e.g., 4001) for the bundled app port = String.to_integer(System.get_env("PORT") || "4001") config :todo_app, TodoAppWeb.Endpoint, url: [host: "localhost", port: port], http: [ # 4. Localhost Binding: Bind to localhost only for security ip: {127, 0, 0, 1}, port: port ], secret_key_base: secret_key_base, # 5. Server Mode: Ensure the server starts automatically server: true end Update mix.exs
Add the release configuration to your mix.exs file under the project function to enable building a self-contained executable.
# ... inside the project function ... releases: [ todo_app: [ include_executables_for: [:unix], applications: [runtime_tools: :permanent] ] ] Step 3: Create the Phoenix Launcher Script
Tauri needs a way to launch the Phoenix release. We'll create a small shell script that handles finding the release executable in both development and bundled environments.
Create the directory and file: mkdir -p scripts && touch scripts/todo_app_launcher
#!/bin/bash set -e # Get the directory of the script SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # Find the release directory (handles both dev and bundled paths) if [ -d "$SCRIPT_DIR/../_build/prod/rel/todo_app" ]; then # Development path RELEASE_DIR="$SCRIPT_DIR/../_build/prod/rel/todo_app" elif [ -d "$SCRIPT_DIR/../Resources/todo_app" ]; then # macOS bundled path RELEASE_DIR="$SCRIPT_DIR/../Resources/todo_app" elif [ -d "$SCRIPT_DIR/../../Resources/_build/prod/rel/todo_app" ]; then # Windows/Linux bundled path (may vary) RELEASE_DIR="$SCRIPT_DIR/../../Resources/_build/prod/rel/todo_app" else echo "ERROR: Could not find Elixir release directory" >&2 exit 1 fi # Set environment variables required by the release export RELEASE_ROOT="$RELEASE_DIR" unset RELEASE_NODE unset RELEASE_DISTRIBUTION echo "Starting Phoenix server from: $RELEASE_DIR" >&2 # Execute the release command, replacing the current shell process exec "$RELEASE_DIR/bin/todo_app" start Make the script executable:
chmod +x scripts/todo_app_launcher Step 4: Initialize and Configure Tauri
Initialize Tauri
Install the Tauri CLI and initialize the project structure.
npm install -D @tauri-apps/cli npx tauri init When prompted, use the following settings:
- App name:
TodoApp - Window title:
TodoApp - Web assets:
../priv/static(This is where Phoenix puts compiled assets) - Dev server URL:
http://localhost:4000(The default Phoenix dev server) - Dev command:
mix phx.server - Build command: Leave empty (we will configure this in
tauri.conf.json)
Configure src-tauri/tauri.conf.json
We need to tell Tauri how to build the Phoenix release and how to bundle it.
Update the build and bundle sections in src-tauri/tauri.conf.json:
{ // ... other configurations ... "build": { "frontendDist": "../priv/static", "devUrl": "http://localhost:4000", "beforeDevCommand": "mix phx.server", // Build assets and create the Phoenix release before bundling "beforeBuildCommand": "MIX_ENV=prod mix assets.deploy && echo 'Y' | MIX_ENV=prod mix release --overwrite" }, "app": { "windows": [ // ... window settings ... ], "security": { // 5. CSP Settings: Must allow WebSocket connections for LiveView "csp": "default-src 'self'; connect-src 'self' ws://localhost:* http://localhost:*; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" } }, "bundle": { "active": true, "targets": "all", "category": "Productivity", // 6. External Binaries: The launcher script is the sidecar "externalBin": [ "../scripts/todo_app_launcher" ], // 7. Resources: Bundle the entire Phoenix release directory "resources": [ "../_build/prod/rel/todo_app" ] } } Step 5: Update Tauri Backend (Rust)
The Rust backend is responsible for launching the Phoenix sidecar, setting environment variables, and waiting for the server to be ready before navigating the webview.
Update src-tauri/Cargo.toml
Add necessary dependencies for process management, logging, and HTTP requests.
[dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.9.1", features = [] } tauri-plugin-log = "2" tauri-plugin-shell = "2" dirs = "5.0" ureq = "2.10" # For synchronous HTTP health check tokio = { version = "1", features = ["time"] } # For async sleep Replace src-tauri/src/lib.rs
Replace the contents of src-tauri/src/lib.rs with the following code. This implements the server health check and sidecar management logic.
use tauri::Manager; use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::process::CommandEvent; // Helper function to wait for the Phoenix server to be ready fn wait_for_server(url: &str, timeout_secs: u64) -> bool { let start = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(timeout_secs); while start.elapsed() < timeout { // ureq is synchronous, which is fine for a simple health check if let Ok(response) = ureq::get(url) .timeout(std::time::Duration::from_secs(2)) .call() { // Phoenix returns 200 or 302 (redirect to LiveView) when ready if response.status() == 200 || response.status() == 302 { return true; } } // Wait for a short period before retrying std::thread::sleep(std::time::Duration::from_millis(500)); } false } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_log::Builder::default() .level(log::LevelFilter::Info) .build()) .setup(|app| { let app_handle = app.handle().clone(); // --- Setup Application Data Directory and Database Path --- let app_support_dir = dirs::data_local_dir() .expect("Failed to get app support directory") .join("TodoApp"); std::fs::create_dir_all(&app_support_dir) .expect("Failed to create app support directory"); let database_path = app_support_dir.join("todo_app.db"); // Skip sidecar logic in development mode if cfg!(debug_assertions) { log::info!("Running in development mode. Phoenix server should be started manually."); return Ok(()); } // --- Production Sidecar Launch Logic --- let database_path_str = database_path.to_str().unwrap().to_string(); let fixed_port = "4001"; // Spawn a new thread to manage the sidecar process std::thread::spawn(move || { let sidecar_command = app_handle.shell() .sidecar("todo_app_launcher") .expect("Failed to create sidecar command"); // Set environment variables for the Phoenix release let (mut rx, _child) = sidecar_command .env("DATABASE_PATH", database_path_str) .env("PHX_SERVER", "true") .env("PORT", fixed_port) .spawn() .expect("Failed to spawn sidecar"); let app_handle_nav = app_handle.clone(); let port_nav = fixed_port.to_string(); // Wait for the server to be ready let url = format!("http://localhost:{}", port_nav); if wait_for_server(&url, 30) { // 30 second timeout if let Some(window) = app_handle_nav.get_webview_window("main") { // Give the server a little extra time before navigating std::thread::sleep(std::time::Duration::from_millis(500)); // Navigate the webview to the running Phoenix server let _ = window.eval(&format!("window.location.replace('{}')", url)); } } else { log::error!("Phoenix server failed to start within 30 seconds."); // TODO: Display a user-friendly error message in the webview } // Log output from the Phoenix sidecar while let Some(event) = rx.blocking_recv() { match event { CommandEvent::Stdout(line) => { log::info!("Phoenix: {}", String::from_utf8_lossy(&line)); } CommandEvent::Stderr(line) => { log::info!("Phoenix stderr: {}", String::from_utf8_lossy(&line)); } CommandEvent::Error(err) => { log::error!("Phoenix error: {}", err); } CommandEvent::Terminated(payload) => { log::warn!("Phoenix terminated: {:?}", payload); break; } _ => {} } } }); Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } Step 6: Build and Run
Development
To run in development mode, you must start the Phoenix server manually in one terminal and the Tauri app in another.
- Start Phoenix Server:
mix phx.server - Start Tauri App:
npx tauri dev Production Build
To create the final, self-contained desktop application:
npx tauri build The built app will be located in src-tauri/target/release/bundle/.
Key Takeaways
| Feature | Phoenix Configuration | Tauri Configuration | Purpose |
|---|---|---|---|
| Localhost Binding | ip: {127, 0, 0, 1} | N/A | Security: Prevents external network access. |
| Fixed Port | port: 4001 | Hardcoded in Rust backend | Reliability: Ensures Tauri knows where to connect. |
| Database Path | System.get_env("DATABASE_PATH") | Set via dirs::data_local_dir() in Rust | Persistence: Stores data in the user's application support directory. |
| LiveView Websockets | N/A | csp: ws://localhost:* | Connectivity: Allows LiveView to establish a WebSocket connection. |
| Bundling | mix release | resources, externalBin | Distribution: Packages the entire Phoenix release and launcher script. |
| Server Discovery | server: true | wait_for_server function in Rust | Reliability: Ensures the webview only loads once the Phoenix server is fully operational. |
Troubleshooting
- Phoenix won't start: Check the Tauri log file, usually located in the application support directory (e.g.,
~/Library/Logs/com.todoapp.desktop/). - Database errors: Ensure the app has write permissions to the application support directory.
- Port conflicts: Change the fixed port (
4001) in bothconfig/runtime.exsandsrc-tauri/src/lib.rs. - Asset issues: Ensure you run
MIX_ENV=prod mix assets.deploybefore building for production.
Conclusion
By combining the robust backend capabilities of Phoenix LiveView with the native desktop packaging of Tauri, you can deliver a high-performance, fully self-contained application that offers a superior user experience compared to traditional web apps. This setup leverages the best of both worlds: the productivity of Elixir/Phoenix and the native power of Rust/Tauri.
Top comments (0)