Deployment & Desktop Apps
- Opting into zero-downtime restarts
- How the handoff actually works
xr optimize- Tagging a release with
xr release xr deploy- Build size and production disk usage
xr run- Surviving a crash or a reboot
- Minimal deployments: relocating just the binary
- Tauri desktop apps
- 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 harddrain_timeoutbackstop 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 buildoriginally 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.envuntouched; 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.