← ./articles

Tauri v2 Updater Builds Need a Separate Config Overlay

Tauri v2's updater is configured in two places that look related but are not controlled by the same switch.

Rust code can be gated with a Cargo feature. Tauri's bundler configuration is read later by the build tool. If updater settings live in the base tauri.conf.json, a normal build can still try to create or sign updater artifacts even when the Rust updater code is disabled.

The fix is to keep the base config updater-free and apply a separate updater overlay only for release builds that are meant to produce signed updater artifacts.

The symptom: a normal build tries to sign updater artifacts

The failure usually appears after compilation, during the bundle step:

Error: missing private key

or:

TAURI_SIGNING_PRIVATE_KEY is not set

This can be confusing when the Rust code is already guarded:

#[cfg(feature = "updater")]
fn init_updater() {
    // updater setup
}

The feature gate controls Rust compilation. It does not remove updater keys from the JSON config that the bundler reads.

Why Cargo features do not gate Tauri bundler config

Cargo features are compile-time inputs for Rust crates. They decide which Rust modules, functions, and dependencies are compiled.

Tauri config is build-tool input. The bundler reads fields such as bundle.createUpdaterArtifacts and plugins.updater from configuration files. If those fields are present in the config used by a default build, the bundler can act on them even when the Rust feature is off.

That means this setup is fragile:

{
  "bundle": {
    "createUpdaterArtifacts": true
  },
  "plugins": {
    "updater": {
      "pubkey": "PASTE THE PUBLIC KEY TEXT HERE",
      "endpoints": ["https://releases.my-app.invalid/latest.json"]
    }
  }
}

The base config now describes an updater build. A default build is no longer really default.

Move updater settings into an overlay config

Use the base config only for settings that apply to every build variant.

{
  "productName": "my-app",
  "version": "1.0.0",
  "build": {
    "devUrl": "http://localhost:1420",
    "frontendDist": "../dist"
  },
  "bundle": {
    "active": true,
    "targets": ["nsis", "msi"]
  },
  "plugins": {}
}

Then create a separate updater config, for example src-tauri/tauri.updater.conf.json:

{
  "bundle": {
    "createUpdaterArtifacts": true
  },
  "plugins": {
    "updater": {
      "pubkey": "PASTE THE PUBLIC KEY TEXT HERE",
      "endpoints": ["https://releases.my-app.invalid/latest.json"],
      "windows": {
        "installMode": "passive"
      }
    }
  }
}

Now the build commands have different meanings:

# Default installer build: no updater artifacts, no signing requirement.
npm run tauri -- build

# Updater release build: updater feature plus updater config overlay.
npm run tauri -- build --features updater --config src-tauri/tauri.updater.conf.json

The important rule is simple: the command that enables the updater feature must also apply the updater config overlay.

Keep the feature gate in Rust too

The overlay prevents the bundler from reading updater config during default builds. It does not replace Rust feature gating.

Keep the runtime setup explicit:

#[cfg(feature = "updater")]
fn init_updater(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
    app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
    Ok(())
}

#[cfg(not(feature = "updater"))]
fn init_updater(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
    Ok(())
}

Then call init_updater() from the normal setup path. Both build variants can use the same application startup flow without loading updater code in the default build.

Verify the artifact, not just the exit code

The most dangerous updater build failure is silent omission.

This command can compile and bundle successfully while forgetting the overlay:

npm run tauri -- build --features updater

If --config src-tauri/tauri.updater.conf.json is missing, the build may still produce an installer. It just may not produce updater signatures.

Add an artifact check to the release process:

# Default build should not create updater signatures.
npm run tauri -- build
test ! -f "src-tauri/target/release/bundle/nsis/my-app_1.0.0_x64-setup.exe.sig"

# Updater build must create updater signatures.
npm run tauri -- build --features updater --config src-tauri/tauri.updater.conf.json
test -f "src-tauri/target/release/bundle/nsis/my-app_1.0.0_x64-setup.exe.sig"

On Windows PowerShell, write the same rule with Test-Path:

if (-not (Test-Path "src-tauri\target\release\bundle\nsis\my-app_1.0.0_x64-setup.exe.sig")) {
  throw "Updater signature was not generated"
}

The exact artifact name depends on your product name, version, architecture, and bundler target. The check should use the real path your release process expects.

When this is not the right fix

Do not add a config overlay just because a build fails.

Use the overlay pattern when all of these are true:

  • the feature is optional
  • the build tool has config keys for that feature
  • those config keys cause work even when the compiled code path is disabled
  • different build variants should produce different artifacts

If every release of the application must always support updater artifacts, a single base config can be acceptable. In that case, the better fix is usually to make the signing environment reliable in CI.

References

Summary

Treat updater artifacts as a separate build variant. Keep the default tauri.conf.json free of updater-only settings, apply an updater overlay only when building updater releases, and verify the expected .sig files after the build. That avoids both false signing failures and successful-looking releases that cannot update.