Production Environment

Manage environment variables for Tauri v2 production builds with React and Vite, including secrets handling, platform differences, and configuration best practices.

A Tauri production build turns your web frontend and Rust backend into a single native executable. That shift changes how environment variables behave. Variables that worked seamlessly in development can silently vanish, expose secrets, or break entirely when you ship the app to users. This page explains what actually happens to environment variables in production, why they behave differently, and how to configure them safely for real-world distribution. Signing credentials used here also belong in code signing.

How Environment Variables Behave in a Tauri Production Build

In development, tauri dev runs your Vite dev server and the Rust binary side by side. The frontend loads variables from .env files through Vite’s built-in loader, and the Rust side has access to your terminal’s full environment. Both sides can see variables freely.

A production build collapses this model. The frontend is compiled into static files by vite build, and those files are embedded into the Tauri binary. At runtime, there is no dev server, no Vite process, and no .env file being read automatically by the frontend. The frontend only knows what was baked into its JavaScript bundle at build time. The Rust backend only sees the environment variables available to the operating system process when the user launches the app — which, on some platforms, is very different from your development shell.

VITE_ variables are build-time only:

Any variable prefixed with VITE_ is inlined into the frontend bundle during vite build. Changing the variable after the build will not affect a shipped application. The value becomes a hardcoded string in your JavaScript.

Frontend Environment Variables with Vite

Vite exposes environment variables to your React code through import.meta.env. Only variables prefixed with VITE_ are included — all others are stripped to prevent accidental leakage of sensitive data. The values are substituted at build time, meaning they cannot change at runtime.

A typical production setup uses a .env.production file in the root of your Vite project (next to package.json):

.env.production
VITE_API_BASE_URL=https://api.myapp.com
VITE_ENABLE_ANALYTICS=true

Inside a React component, access these variables just like any other import:

src/App.tsx
const API_BASE = import.meta.env.VITE_API_BASE_URL;
const analyticsEnabled = import.meta.env.VITE_ENABLE_ANALYTICS === "true";
function App() {
  return (
    <div>
      <p>API endpoint: {API_BASE}</p>
      {analyticsEnabled && <p>Analytics are active</p>}
    </div>
  );
}

When you run tauri build, the CLI executes the beforeBuildCommand (usually npm run build), which triggers vite build. Vite automatically loads .env.production when NODE_ENV is production. The result is that the values from that file are frozen into the final JavaScript bundle inside your binary.

Never put secrets in VITE_ variables:

API keys, database passwords, and authentication tokens placed in a VITE_ variable become plaintext strings in your app’s JavaScript bundle. Anyone who opens the app’s ASAR or binary can extract them. Use the Rust backend for anything sensitive.

Customizing the Build Mode

If you need separate staging and production builds, Vite supports custom modes. You can create .env.staging and then instruct Vite to use it:

src-tauri/tauri.conf.json
{
  "build": {
    "beforeBuildCommand": "npm run build:staging",
    "beforeDevCommand": "npm run dev",
    "distDir": "../dist"
  }
}
package.json
{
  "scripts": {
    "build:staging": "vite build --mode staging"
  }
}

Vite will then load .env.staging and apply the same VITE_ prefix rules.

Backend (Rust) Runtime Environment Variables

The Rust side of a Tauri app does not have access to variables that were only set during the frontend build. It reads from the operating system’s environment at runtime, using std::env::var. This means you can read values that the user’s system or a launcher script provides, but you cannot rely on a .env file unless you explicitly load it yourself.

A common pattern for exposing environment information to the frontend is through a Tauri command:

src-tauri/src/main.rs
#[tauri::command]
fn get_config(key: String) -> Result<String, String> {
    match key.as_str() {
        "log_level" => std::env::var("MY_APP_LOG_LEVEL")
            .unwrap_or_else(|_| "info".to_string()),
        _ => Err(format!("Unknown config key: {}", key)),
    }
    .map_err(|e| e.to_string())
}
fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![get_config])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

On the frontend, you call it with invoke:

import { invoke } from "@tauri-apps/api/core";
async function fetchLogLevel() {
  const level = await invoke("get_config", { key: "log_level" });
  console.log("Log level from backend:", level);
}

This approach gives you control over exactly which environment variables the frontend can see. You never expose the raw environment wholesale.

Gateway access is correct:

Restricting the frontend to only the variables it genuinely needs is the right pattern. The Rust backend acts as a gatekeeper — it decides which values are safe to share, and it can provide fallbacks when a variable is missing.

Loading .env Files from Rust

If you want to use a .env file on the Rust side (for example, during development or in controlled production environments), the dotenvy crate can load it at startup. This is not automatic — Tauri does not parse .env files for the Rust process.

Add the dependency:

src-tauri/Cargo.toml
[dependencies]
dotenvy = "0.15"

Then load at the top of your main function:

src-tauri/src/main.rs
fn main() {
    // Load .env file if present — fails silently if not found
    let _ = dotenvy::dotenv();
    tauri::Builder::default()
        // ...
}

This loads variables into the process environment before Tauri starts. However, for production, be aware that the .env file must be present alongside the executable or bundled as a resource. On platforms where the app is sandboxed or launched from a different working directory, dotenvy may not find the file.

Build‑Time Environment Detection with TAURI_ENV_* Variables

Tauri’s CLI sets a set of environment variables for every hook command (beforeDevCommand, beforeBuildCommand, etc.). These tell you what kind of build is happening and can be used inside scripts to adjust behavior.

The most useful for production are:

  • TAURI_ENV_DEBUGtrue for tauri dev or tauri build --debug, false for a release build
  • TAURI_ENV_TARGET_TRIPLE — e.g., x86_64-unknown-linux-gnu
  • TAURI_ENV_PLATFORMwindows, darwin, or linux
  • TAURI_ENV_ARCHx86_64, aarch64, etc.

You can reference these in your beforeBuildCommand to conditionally set frontend variables or run platform‑specific preparation. For example, a Node.js script that injects the target triple as a VITE_TARGET_TRIPLE variable:

src-tauri/tauri.conf.json
{
  "build": {
    "beforeBuildCommand": "node scripts/build-setup.js && npm run build",
    "distDir": "../dist"
  }
}
scripts/build-setup.js
const fs = require("fs");
const debug = process.env.TAURI_ENV_DEBUG === "true";
const target = process.env.TAURI_ENV_TARGET_TRIPLE || "unknown";
const envContent = `VITE_TARGET_TRIPLE=${target}\nVITE_DEBUG_MODE=${debug}\n`;
fs.writeFileSync(".env.build", envContent);
console.log(`Build env written for target: ${target}`);

Then configure Vite to load that file as well (e.g., by adding --mode build and using a .env.build file). This keeps your build configuration declarative and source‑controlled.

TAURI_ENV_ variables are only in hook commands:

These variables exist only during the execution of the beforeDevCommand and beforeBuildCommand. They are not available to the final application at runtime.

Secrets Management for Production

The hardest part of production environment configuration is handling secrets — API keys, tokens, database credentials — without exposing them. The rule is straightforward: the frontend gets nothing sensitive, and the Rust backend handles all secrets.

A secure flow looks like this:

  1. The secret is stored in the operating system’s environment on the machine that builds the app (or is injected by a CI/CD pipeline).
  2. At build time, the Rust code embeds the secret using the env! macro if it is genuinely compile‑time and never changes. For secrets that vary per deployment, you embed a placeholder and fetch the real value at runtime from a secure source.
  3. At runtime, the Rust backend reads the secret from a known environment variable, a configuration file, or a platform‑specific credential store.
  4. The frontend never sees the secret; it calls Tauri commands that use the secret internally.

Example of embedding a build‑time secret (suitable for a public token that is not a security risk if extracted):

src-tauri/src/main.rs
const SENTRY_DSN: &str = env!("SENTRY_DSN");

For runtime secrets, the backend reads the environment and uses the value within a command without returning it to the frontend:

src-tauri/src/main.rs
#[tauri::command]
async fn fetch_user_data(user_id: String) -> Result<String, String> {
    let api_key = std::env::var("MY_API_KEY")
        .map_err(|_| "API key not configured".to_string())?;
    // Use api_key to call an external service, never send it back
    let data = some_api_client::get_user(&api_key, &user_id).await;
    Ok(data)
}

Secrets in the environment are still readable:

On the user’s machine, any process with the same privileges can read the environment variables of your app. A determined user can inspect them. For truly sensitive material, consider platform keychains or encrypted configuration files, not plain environment variables.

Platform Differences in Production

Environment variables do not work the same way on every operating system. A Tauri app that runs fine in development on your Linux workstation may behave differently when a macOS user launches it from Finder.

Linux

Linux apps typically inherit the environment from the shell or desktop launcher. If the user starts your app from a terminal, it sees all exported variables. If started from a desktop file (.desktop), only variables explicitly set in that file are available. To ensure variables are present, you can ship a wrapper script or specify them in the .desktop file:

[Desktop Entry]
Type=Application
Name=MyApp
Exec=env MY_VAR=production /usr/bin/my-app

macOS

Apps bundled as .app packages and launched from Finder do not inherit your shell environment. They see a minimal set of system variables. To supply environment variables, you can add them to the app’s Info.plist under the LSEnvironment key. Tauri allows you to configure this through tauri.conf.json:

src-tauri/tauri.conf.json
{
  "bundle": {
    "macOS": {
      "infoPlist": {
        "LSEnvironment": {
          "MY_APP_CONFIG_PATH": "/etc/myapp/config.json"
        }
      }
    }
  }
}

This embeds the variable into the app bundle, so it is always present regardless of how the app is launched.

Windows

Windows apps inherit environment variables from the system and user environment blocks. You can set them globally via System Properties or through the registry. For app‑specific variables, a common approach is to use an installer that writes a registry key or a launcher batch file that sets the environment before invoking the executable.

Preferred method: Add LSEnvironment to Info.plist through tauri.conf.json. The variable becomes part of the app bundle and works regardless of how the user launches the app. Avoid depending on shell‑exported variables.

Environment Variable Best Practices for Production

  • Separate public configuration from secrets. Put public values (API base URLs, feature flags) in VITE_ variables. Handle secrets exclusively in Rust, and never pass them to the frontend.
  • Use the TAURI_ENV_ variables in hook scripts* to adjust builds per environment without hardcoding flags in your CI configuration.
  • Define defaults for every variable. On the Rust side, use unwrap_or or unwrap_or_else so a missing variable does not crash the app. On the Vite side, consider defining fallback values in a .env file that is always loaded.
  • Document every expected environment variable. A README or configuration section in your docs should list what each variable does, whether it is required, and a safe default. This helps anyone packaging the app later.
  • Test on the target platform early. Verify that your environment variables are accessible on macOS when launched from Finder, and on Linux when started via a desktop file. Do not assume the development environment mirrors production.
  • Avoid logging environment variable values. A stray println!("Using key: {}", api_key) in Rust or console.log(import.meta.env.VITE_API_KEY) in React will expose secrets in logs or developer tools.
  • Keep Vite .env files out of your Tauri bundle. The frontend is already compiled; including .env files as Tauri resources does nothing for the frontend and may mislead you into thinking they are loaded. If you need a config file at runtime for the Rust side, use a separate JSON or TOML file and read it with std::fs.

Environment configuration is correct when:

You can build the app on a clean CI machine with only the required secrets set as environment variables, and the resulting binary behaves identically to the one built locally, without depending on any local .env files or shell state.

Common Mistakes to Avoid

  • Assuming process.env works everywhere. Vite replaces process.env usage with the appropriate import.meta.env pattern. Using process.env.VITE_API_URL in a Tauri app with Vite will fail or produce unexpected results. Stick to import.meta.env.
  • Expecting runtime environment changes to affect a compiled frontend. Changing VITE_API_URL on the user’s machine after the build has no effect. The frontend is static.
  • Loading .env from the Rust side without considering the working directory. When a Tauri app is launched, its current working directory is often not the app’s resource folder. dotenvy::dotenv() may look in the wrong place. Always test with the final installed package.
  • Exposing the entire environment to the frontend through a generic get_env command. A command like fn get_env(key: String) -> String { std::env::var(&key).unwrap() } lets the frontend request any variable, including secrets. Always whitelist which keys are accessible.

The shift from development to production in Tauri requires intentionally managing what gets compiled, what gets read at runtime, and how the operating system hands your app its environment. With the patterns on this page, your builds stay predictable across machines and your secrets stay where they belong.