Deployment & Desktop Apps

  1. Opting into zero-downtime restarts
  2. How the handoff actually works
  3. xr optimize
  4. Tagging a release with xr release
  5. xr deploy
    1. Reclaiming build space
  6. Build size and production disk usage
  7. xr run
  8. Surviving a crash or a reboot
  9. Minimal deployments: relocating just the binary
    1. Registering a minimal deployment with systemd
  10. Tauri desktop apps
    1. Published release retention
  11. Next

Opting into zero-downtime restarts

By default, a generated app behaves exactly like any plain axum::serve program - Ctrl+C exits immediately. Zero-downtime restart is a real, opt-in change to your process-lifecycle behavior, in two independent steps:

Application::at_root(env!("CARGO_MANIFEST_DIR"), config::app::config)?
    .router(route.into_axum_router())
    .with_graceful_shutdown(GracefulShutdown {
        drain_timeout: Duration::from_secs(30),
        restart_channel: true,
    })
    .serve().await
  • No .with_graceful_shutdown(...) call at all → unchanged, original behavior.
  • restart_channel: false → graceful shutdown on Ctrl+C/SIGTERM only (a smaller, legitimate feature on its own): stop accepting new connections, drain in-flight ones, with a hard drain_timeout backstop so a stuck connection can’t block a shutdown forever.
  • restart_channel: true → the full mechanism below.

xr new doesn’t enable either by default - this is a deliberate step your app takes once you understand the drain-timeout trade-off, not silently baked in.

How the handoff actually works

No external supervisor, reverse proxy, or process manager required (and nothing here conflicts with one either, if you already use one). The currently-running process spawns its own replacement, hands it the exact same listening socket it was already using (real inter-process socket-passing - fcntl-based fd inheritance on Unix, WSADuplicateSocketW on Windows - not a second process racing for the same port), waits for the replacement to confirm it’s genuinely serving, and only then drains its own in-flight requests and exits. Verified with real subprocess integration tests driving continuous HTTP traffic through an actual restart, with zero failed requests across the handoff.

xr restart

Sends the restart command over a local admin channel (a Unix socket / Windows named pipe). xr dev and xr deploy both build on this exact same mechanism internally - every rebuild during xr dev is a zero-downtime handoff, not a stop-then-start.

xr optimize

xr optimize

Prebuilds the production artifacts xr deploy needs—frontend assets when the app has a node_modules/ pipeline, then Cargo’s optimized release binary—without publishing a release, changing storage/releases/, or restarting a running app. It is useful in CI or as an explicit production-build check before deployment.

Unlike Laravel’s optimize, it does not build a mutable runtime PHP/config/route cache. Larust applications are compiled Rust: routes, templates, and framework code are already compiled into the release binary. After it passes, run xr deploy when you are ready to publish a production build.

Tagging a release with xr release

xr release [--major|--minor|--patch]

A different “release” from storage/releases/ below - this one tags a version in git, before any build happens; storage/releases/ holds the compiled binaries xr deploy actually publishes. The two are independent on purpose: .env lives at the app’s root, outside git entirely (it’s gitignored by default), and storage/releases/’s own published binaries resolve their root from env!("CARGO_MANIFEST_DIR")

  • a compile-time constant baked in wherever cargo build originally ran, not wherever the binary file itself ends up - rather than from whatever’s currently checked out. So every release generation, whatever git tag produced it, reads the exact same .env untouched; tagging a release never needs to carry or duplicate it.

With a bump flag, rewrites Cargo.toml’s version and commits that change (chore: release vX.Y.Z); with no flag, tags whatever version is already there. Either way it then builds release notes from every commit since the last tag (grouped by commit type - feat/fix/ refactor/perf/docs/test/ci/chore) and creates a local, annotated vX.Y.Z tag carrying those notes.

Never pushes, never deploys - it prints the exact git push && git push origin vX.Y.Z to run yourself when ready, and xr deploy (below) is still the separate step that actually builds and restarts the app, on whichever machine serves it. See CLI Reference for the full flag reference.

This doesn’t wire up automatic deploy-on-push - a bare git remote on the server plus a post-receive hook that runs git checkout then xr deploy locally (“push to deploy,” the same shape Dokku/classic Capistrano-style tooling uses) is a separate, already-well-understood pattern you’d set up by hand on the server if you want it; xr release only prepares the tag.

xr deploy

xr deploy [--run] [--service] [--clean | --clean-all]
# .env
DEPLOY_TYPE=web   # default

cargo build --release, publish to a fresh, monotonically-increasing slot under storage/releases/ (release builds and xr dev’s own dev builds use separate counting namespaces, so one can’t prune the other’s files away), then the same restart-handoff xr restart uses against whatever’s currently running. Builds frontend assets first (npm run build) when node_modules/ exists, stopping the deploy outright if that build fails - a broken asset build must never ship.

Reclaiming build space

xr deploy --clean
xr deploy --service --clean
xr deploy --clean-all          # also remove debug/development build caches

For web deployments, --clean runs cargo clean --release and --clean-all runs plain cargo clean. Both run after publishing and after any restart or requested startup step returns success. A failed build, publish, restart, or startup skips cleanup. Publishing without starting an app also counts as success. A detached startup confirms spawning, not application readiness; cleanup adds no new readiness guarantee.

--clean removes compiled release dependencies and intermediates, preserving development caches. --clean-all also removes debug dependencies, tests, and other Cargo target artifacts, making it useful for production servers that have accumulated a large target/debug/deps/. Both preserve published binaries in storage/releases/, frontend assets, and runtime data outside Cargo’s output directories. Neither clears node_modules/ or Cargo’s shared downloaded registry/git source caches.

Choose one cleanup flag; using both is an error. --clean-all means the next development build also recompiles dependencies. For manual cleanup, run cargo clean from the application root. Keep published binaries and runtime data outside Cargo’s output directories.

The next deploy must rebuild dependencies and takes longer. The server still needs room for a full build before cleanup; this reduces retained disk usage, not peak build space.

Cargo resolves custom target directories and workspace settings. If several projects share the same target directory, cleanup affects the selected caches for all of them. Keep published releases/runtime data outside Cargo’s build output directory. DEPLOY_TYPE=app rejects both cleanup flags before building because Tauri bundles live in that directory. If cleanup itself fails, the command reports an error stating the release was already deployed; it does not roll back the deployment.

The very first deploy of an app that’s never been started has nothing running to hand off to; --run cold-starts the freshly published binary in the background for you instead of just publishing it and leaving you to start it manually.

xr deploy --run starts the app for as long as the machine and its process table live - nothing about it survives a reboot, or restarts the app if it actually crashes. xr deploy --service (below) is the one-line fix; plain --run alone is really only worth reaching for on a throwaway/test box you don’t care about surviving a restart.

xr deploy --run sends the app’s stdout/stderr to nowhere, for its entire life - not just this first process, but every later zero-downtime restart handoff too, since each replacement inherits its own stdout from its immediate predecessor. With LOG_CHANNEL left at its default ("stdout"), that means no log output ever, anywhere, for an app started this way - not an error, just silence. Set LOG_CHANNEL=file (or stack) in .env before using --run if you want any record of what the app does. xr deploy --service doesn’t have this problem - systemd’s own default captures stdout/stderr into the journal (journalctl) instead of discarding it.

Build size and production disk usage

xr deploy publishes only the release executable and reports its size. Multi-gigabyte Cargo target/ directories hold intermediate dependency artifacts, debug symbols, incremental caches, and test binaries. These are build-time disk usage, not the size of the running framework.

New apps and the framework workspace use these profiles. Existing apps can add them to their root Cargo.toml (dependency profiles are ignored):


# Keep app line numbers; omit dependency debug information.
[profile.dev]
debug = 1

[profile.dev.package."*"]
debug = 0

# Retain panic unwinding for HTTP panic handling.
[profile.release]
lto = "thin"
strip = "symbols"
incremental = false

Development retains incremental rebuilds and app line numbers while omitting dependency debug information. Set debug = 2 in both dev tables for full debugger support. Thin LTO trades longer release builds for cross-crate optimization; stripping symbols reduces production debugging information. Panic unwinding stays enabled for HTTP panic handling.

For a small production installation, build on a separate machine or in CI for the destination OS/architecture. Ship the executable, public/ (including built frontend assets), runtime configuration, and writable storage/database files your app needs. Running the executable does not require Rust, Cargo, framework sources, target/, or node_modules/. Keep xr too if you use its runtime management commands. xr deploy still builds locally and needs sources and a toolchain; it does not yet export a portable runtime bundle.

Generated apps anchor their root to the compile-time CARGO_MANIFEST_DIR by default. Before building for a different runtime location, set LARUST_APP_ROOT (see “Minimal deployments” below) rather than editing paths()/application() in src/lib.rs by hand - both already read through larust_core::resolve_app_root, so the override alone is enough; no source change needed. If copying release slots, regenerate storage/releases/current on the destination: it contains an absolute path.

Use xr deploy --clean for release artifacts, or xr deploy --clean-all to reclaim both release and development build artifacts automatically. After a successful local deploy, cargo clean --release also reclaims release build artifacts while preserving published copies in storage/releases/. The next deploy rebuilds dependencies. cargo clean also removes development caches. Neither removes runtime storage or caches outside Cargo’s target directory. Avoid cleaning after every dev build: caches speed up rebuilds. The release publisher retains three generations per prefix.

Binary size depends on features, database drivers, templates, dependencies, and platform. Measure the executable separately from caches and app data.

xr run

xr run

Starts the currently-published release (whatever storage/releases/ current points at) if nothing is already listening on the app’s own port - a no-op, not an error, if something already is. For when publishing and starting need to be separate, independently-auditable steps - xr deploy (no flags) publishes a release without starting anything at all, and there was previously no dedicated way to start that already-published release afterward without either invoking its binary directly (skipping the “already running?” check xr deploy --run gets for free) or triggering a whole rebuild-and-republish via xr deploy --run again just to start something already sitting there ready to go. Also the right command to bring a release back up after it was stopped (xr kill, or a crash with no xr service:install in place), with no rebuild involved.

Surviving a crash or a reboot

xr deploy --service     # first deploy: build, publish, and register with systemd, in one line
xr service:install      # equivalent, if you'd rather do it as its own separate step
xr service:uninstall    # undo it

--service is --run’s systemd-backed sibling, for the exact same “nothing is running to hand off to yet” moment: instead of a bare detached process, it calls the same xr service:install logic below directly, so the very first deploy is already crash-and-reboot-safe with no second command needed. Wins over --run if both are given - starting the app both ways at once would just race each other for the same port. Same scope as --run otherwise: no effect once something is already running (every later xr deploy just hot-swaps it, exactly as before), none on DEPLOY_TYPE=app builds.

Linux (systemd) only today. Registers the app as a real systemd service: WorkingDirectory points at the app’s own root, ExecStart re-reads storage/releases/current fresh every time systemd actually starts the service (not a resolved path snapshotted once at install time - see below for why that distinction matters), Restart=on-failure brings it back after an actual crash, and WantedBy=multi-user.target (via systemctl enable) starts it automatically on boot - closing exactly the gap a plain xr deploy --run leaves open.

ExecStart always resolves the current release, not whatever was current at install time - deliberately, after a real incident: an earlier version of this command baked a specific, resolved release path into the unit file, which later xr deploys would eventually prune away (only the 3 most recent releases are kept on disk), leaving the service unable to start on the next crash or reboot with status=203/EXEC. xr service:install only ever needs running once per app for this exact reason - no re-run required after any later deploy, ever.

Deliberately Restart=on-failure, not Restart=always: this framework’s own zero-downtime restart handoff works by having the old process spawn its own replacement, hand off the listening socket, drain in-flight requests, and only then exit cleanly - a normal, successful exit on every single ordinary xr deploy/xr restart, not a failure. Restart=always can’t tell that apart from a real crash: systemd would see the old process exit and spawn another fresh instance racing the handoff’s own already-running replacement for the same port - the zero-downtime mechanism broken by the very thing meant to keep the app running. on-failure only ever fires on an actual crash (a non-zero exit or a killing signal), so ordinary deploys keep working exactly as before - xr service:install is a one-time setup step, not something later deploys need to know about.

If /etc/systemd/system/ isn’t writable (not running as root), it prints the unit file and the exact sudo commands to run instead of failing silently.

Minimal deployments: relocating just the binary

# e.g. a systemd unit's own [Service] section
Environment=LARUST_APP_ROOT=/opt/myapp

By default, a compiled app looks for .env/database//storage/ at env!("CARGO_MANIFEST_DIR") - a value fixed at cargo build time to wherever the build happened to run, not wherever the resulting binary is actually placed afterward. That’s invisible as long as you build in place on the server (what xr deploy does today), but it means copying a binary built elsewhere - a CI runner, a different box - into a fresh directory with nothing but that binary and an .env file next to it silently resolves every path against the original build machine instead, not an error you’d necessarily notice: xr migrate reports success while quietly writing the database file back to wherever it was built, not where you actually ran it from.

Confirmed directly, not just reasoned about: relocating a built binary + .env to an empty directory and running migrate with no override creates database.sqlite back at the original build directory, leaving the new directory’s own database/ empty - a genuinely silent misdirection, not a clean failure you’d catch quickly.

LARUST_APP_ROOT (a real process environment variable - never read from .env, since .env’s own location is exactly what this decides, so sourcing the override from inside the file it’s used to find would be circular) fixes this: set it to wherever the binary actually lives, and every path resolves there instead of the compile-time default. This is what makes “just the binary and an .env file” an honest minimal deployment, rather than one that happens to work only when the server’s own directory layout coincidentally matches wherever it was built.

Registering a minimal deployment with systemd

xr service:install --simple ./myapp-bin

The ordinary xr service:install (above) still requires a real storage/releases/current pointer - i.e., xr deploy to have published at least once - which a relocated-binary deployment with no xr deploy history at all doesn’t have. --simple <path> skips that requirement (and the Cargo.toml check) entirely: it registers ExecStart pointing directly at <path> - no pointer-file indirection to re-read, since there’s no separate “current release” to track - and, critically, also writes Environment=LARUST_APP_ROOT=<current directory> into the unit file itself. Without that line, --simple would quietly reintroduce the exact misdirection described above: WorkingDirectory alone doesn’t make the running app resolve its own paths against the new location, only LARUST_APP_ROOT does.

Run it from wherever the binary and .env actually live - that directory becomes both WorkingDirectory and the LARUST_APP_ROOT value. <path> can be relative to that directory or absolute. Same Restart=on-failure reasoning as ordinary xr service:install applies unchanged: a minimal deployment can still use the zero-downtime restart handoff, which exits cleanly on every ordinary restart - not just on a crash.

Tauri desktop apps

xr new myapp --tauri     # scaffold Tauri support from the start
xr add tauri              # or retrofit it onto an existing app
# .env
DEPLOY_TYPE=app
xr deploy       # cargo tauri build, from src-tauri/

Uses an “embedded local server” model: src-tauri/src/main.rs spawns your app’s own serve() on a background tokio runtime, then points a native OS webview at http://127.0.0.1:{port} - one real router, reached over loopback instead of a browser tab, not a rewritten or parallel implementation. This is exactly why a generated app’s boot sequence lives in src/lib.rs (connect_database/port/application/router/serve) rather than inline in src/main.rs’s main() - an embedded host reuses those functions directly instead of spawning your compiled binary as a subprocess.

src-tauri/Cargo.toml is a plain sibling crate depending on your app via path = "..". Icons aren’t generated at scaffold time - cargo tauri build fails with an install-style hint (cargo tauri icon <path>) if src-tauri/icons/ is still empty, the same “clear hint, no silent half-working state” posture xr audit takes for a missing cargo-audit install.

Published release retention

Published executables under storage/releases/ are separate from Cargo’s build caches. Each publish prunes older binaries, keeping the current and two preceding generation numbers independently for dev-* and release-*. The slot referenced by current is also preserved if it is older. Deletion failures (for example, a still-running Windows executable) are reported and retried on later prune passes. Dev sessions retry every 30 seconds while watching, including when no source files change. Deployments retry on the next publish. Pruning does not remove another prefix’s slots or Cargo caches; use --clean/--clean-all for build-cache cleanup.

Next

You’ve now covered the whole framework surface. Converting a Laravel App if you’re bringing an existing project over, or back to Home for the full map.