> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://rivet.chrisboulton.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://rivet.chrisboulton.com/_mcp/server.

# SSH from a controller

> Collect facts, plan, and apply on remote hosts from a Mac or Linux machine.

Add `--target` to `facts`, `plan`, `apply`, and `journal` to run them on a remote host over SSH. Compilation stays on your machine.

```sh
rivet facts --target app-1 > app-1.json
rivet compile ./site --facts app-1.json --out app-1.bundle
rivet plan app-1.bundle --target app-1 --sudo
rivet apply app-1.bundle --target app-1 --sudo
rivet journal --target app-1 --sudo
```

`--target` accepts anything OpenSSH does: `user@host` or a `Host` alias from your SSH config. The host must run Ubuntu 24.04 with systemd on x86_64 or ARM64. Applying requires root, so either log in as root (`--target root@app-1`) or add `--sudo`.

## Connecting

Rivet runs your system `ssh` with strict host-key checking, `BatchMode`, and no terminal. Bastions, aliases, and keys from your SSH config and agent work as usual, but nothing can prompt: enroll host keys and set up key authentication first. `--ssh-config FILE`, `--ssh-user USER`, and `--ssh-port PORT` override settings for every connection Rivet makes.

`--sudo` runs `/usr/bin/sudo -n /bin/sh -c ...`, so the login user needs passwordless sudo for a shell. A sudo rule that allows only the Rivet binary isn't enough. Running arbitrary catalogs as root is root-equivalent anyway, because catalogs can contain commands.

## How the runner gets there

Rivet first looks for a compatible runner already on the host: on `PATH`, in `/usr/local/bin` or `/usr/bin`, or in Rivet's runner cache. "Compatible" means it supports this bundle's formats and the exact capabilities it needs; the Rivet versions don't have to match.

If there isn't one, Rivet uploads a runner to a private cache, verifying it before use:

* Root uses `/var/cache/rivet/runners/<digest>/rivet`.
* Other users use `~/.cache/rivet/runners/<digest>/rivet`.

The two caches are separate. Collecting facts as an unprivileged user doesn't install a runner for later `--sudo` runs. Rivet never overwrites an installation you manage, and root never executes a binary a user could have modified.

Use `--installed-only` to refuse uploads and require a runner that is already installed.

## Linux artifacts from a Mac

A macOS build can't run on Linux, and Rivet doesn't cross-compile. Build Rivet on Ubuntu 24.04 for the host's architecture and pass it with `--runner-binary` the first time:

```sh
rivet facts --target root@app-1 --runner-binary ./rivet-linux-aarch64 > app-1.json
rivet compile ./site --facts app-1.json --out app-1.bundle
rivet plan app-1.bundle --target root@app-1 --installed-only
rivet apply app-1.bundle --target root@app-1 --installed-only
```

Facts collection uploads the runner, and later commands reuse it from the cache. Use the same login identity throughout, since each identity has its own cache. `--runner-binary` uploads its file even if a compatible runner exists, which is also how you replace an older runner that lacks a capability you need.

Packaged releases will ship a `runner-artifacts.json` next to the controller binary, pinning the Linux runners that match it; `--runner-manifest FILE` selects one explicitly. No packaged releases are published yet, so source builds need `--runner-binary` or a runner installed on the host.

## Paths and plugins

`--facts-dir` and `--state-dir` refer to paths on the remote host. `--ssh-config`, `--runner-binary`, and `--runner-manifest` refer to paths on your machine.

[Plugins](/contributing/plugins) must be installed and approved on the host itself, normally in `/etc/rivet/plugins.lock.json`. A `--plugin-lock` on the controller applies only to the controller. Uploading a runner doesn't install plugins, and a missing plugin is reported before any change.

## Noop, output, and interruptions

Remote `plan` and `apply --noop` make no managed changes, but they can upload and cache a runner. Add `--installed-only` if that matters. Reports and exit codes are the same as for local runs.

If the connection drops after an apply starts, the outcome is unknown: the remote run may have finished, stopped partway, or still be running. Rivet doesn't retry automatically. Check before applying again:

```sh
rivet journal --target app-1 --sudo
```

A fresh apply then converges normally, re-checking guards and delivering any pending refreshes. See [interrupted SSH runs](/reference/recovery#interrupted-ssh-runs).

## Limits

`--timeout-seconds N` limits a runner session to N seconds, from 1 to 86400 (the default). Bootstrap and probes have their own shorter limits. Bundles and runner executables can be up to 256 MiB, facts snapshots and captured command output up to 1 MiB, and retained command output up to 16 MiB per run; truncated output is marked. Bundles are sent over stdin and not kept on the host.