DEV Community

Cover image for Building a Phoenix LiveView Desktop App with Tauri: A Step-by-Step Guide
David Teren
David Teren

Posted on

Building a Phoenix LiveView Desktop App with Tauri: A Step-by-Step Guide

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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] ] ] 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Make the script executable:

chmod +x scripts/todo_app_launcher 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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" ] } } 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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"); } 
Enter fullscreen mode Exit fullscreen mode

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.

  1. Start Phoenix Server:
 mix phx.server 
Enter fullscreen mode Exit fullscreen mode
  1. Start Tauri App:
 npx tauri dev 
Enter fullscreen mode Exit fullscreen mode

Production Build

To create the final, self-contained desktop application:

npx tauri build 
Enter fullscreen mode Exit fullscreen mode

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 both config/runtime.exs and src-tauri/src/lib.rs.
  • Asset issues: Ensure you run MIX_ENV=prod mix assets.deploy before 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)