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)