> ## Documentation Index
> Fetch the complete documentation index at: https://orbit.nckrtl.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# node

> Add machines to the fleet, rename Nodes, set unmanaged WireGuard keys, assign and move roles, grant access, set the apps root, read metrics, and remove Nodes.

A Node is a machine that the Gateway manages over SSH and reaches over WireGuard. The `node` family adds a machine, converges it again, renames it, assigns and moves its roles, records which Node may act on which, sets its apps root, and removes it.

[Node provisioning](/docs/reference/node-provisioning) explains what add, converge, role changes, and removal do on the machine. [Node settings](/docs/reference/node-settings) owns the apps root.

## Commands

| Command | Result |
| - | - |
| [`node:add`](#orbit-nodeadd) | Add a machine, or converge an existing Node. |
| [`node:converge`](#orbit-nodeconverge) | Re-apply the Gateway-rendered Orbit footprint of a Node. |
| [`node:list`](#orbit-nodelist) | List the Nodes. |
| [`node:show`](#orbit-nodeshow) | Show one Node. |
| [`node:metrics`](#orbit-nodemetrics) | Show one metrics snapshot of a Node. |
| [`node:rename`](#orbit-noderename) | Change the name of a Node. |
| [`node:settings`](#orbit-nodesettings) | Set the apps root of a Node. |
| [`node:wireguard-key`](#orbit-nodewireguard-key) | Set an unmanaged Node's WireGuard public key and reconverge the hub. |
| [`node:role:add`](#orbit-noderoleadd) | Add a role, or converge an existing role. |
| [`node:role:list`](#orbit-noderolelist) | List the roles of a Node. |
| [`node:role:relocate`](#orbit-noderolerelocate) | Move `gateway`, `websocket`, or `metrics` to another Node. |
| [`node:role:remove`](#orbit-noderoleremove) | Remove a role. |
| [`node:access:add`](#orbit-nodeaccessadd) | Grant one Node access to another. |
| [`node:access:remove`](#orbit-nodeaccessremove) | Remove an access grant. |
| [`node:remove`](#orbit-noderemove) | Remove a Node and hand the machine back. |
| [`node:excluded-project:add`](#orbit-nodeexcluded-projectadd) | Keep a Project off an `app-dev` Node. |
| [`node:excluded-project:list`](#orbit-nodeexcluded-projectlist) | List the Projects that cannot use a Node. |
| [`node:excluded-project:remove`](#orbit-nodeexcluded-projectremove) | Let a Project use the Node again. |

Every command accepts `--json`. `node:show`, `node:remove`, and `node:access:*` take a numeric Node ID. The other commands also accept a Node name.

Lists use tables. Below the minimum table width, each row becomes a labeled record. Details use an aligned tree, with an em dash for an empty value. While the Gateway works, the CLI shows one progress indicator per request, because it does not receive the steps. JSON output contains only the response.

Destructive commands ask for confirmation. The default answer is No. Declining, Ctrl-C, or end of input exits 1 and changes nothing. Without a terminal, and with `--json`, pass `--force`. `--offline` and `--purge-data` never give consent.

## orbit node:add

Add a machine to the fleet. For an existing Node, the same command converges it again after you change its TLD, roles, or settings.

```bash theme={"dark"}
orbit node:add <name> [host] [options]
```

| Argument | Required | Meaning |
| - | - | - |
| `name` | yes | Unique Node name. |
| `host` | for a new Node | Public SSH host. |

| Option | Default | Meaning |
| - | - | - |
| `--host-key-fingerprint=SHA256` | none | Approved SSH host key fingerprint. Required for a new Node. |
| `--ssh-port=PORT` | `22` | Public SSH port. |
| `--user=USER` | `root` for a new Node, the managed user for an existing Node | Bootstrap SSH user. |
| `--orbit-user=USER` | `orbit` for a new Node | Managed user that every later Gateway command uses. |
| `--platform=PLATFORM` | `linux` | `linux` for Ubuntu service Nodes, or `macos` for tools on an existing account and tunnel. |
| `--architecture=ARCH` | the observed value | Machine architecture, such as `x86_64`. A given value must equal the observed one. |
| `--tld=TLD` | none | Node TLD. Required for `app-dev`, unless the Node joins an active Cluster with a TLD. A production Node needs it for [clone](/docs/cli/instance#orbit-instanceclone) preview domains. |
| `--role=ROLE` | none | Role to assign. Repeat for more roles. Without it, see [Nodes without roles](/docs/reference/node-provisioning#nodes-without-roles). |
| `--cluster=ID` | none | Cluster to join. |
| `--wireguard-ip=IP` | generated | Stable WireGuard address. |
| `--lan-ip=IP` | none | LAN IPv4 address that a Cluster Router uses to reach this Node. |
| `--wireguard-endpoint=HOST:PORT` | generated | WireGuard endpoint override. See [WireGuard endpoints](/docs/reference/wireguard-endpoints). |
| `--dns-server=IP` | Orbit VPN DNS | DNS server override. See [Private DNS](/docs/reference/private-dns). A [Node without roles](/docs/reference/node-provisioning#nodes-without-roles) does not use it. |
| `--setting=PATH:VALUE` | none | Node setting. The only path is `apps.path`. |

`--role` accepts `app-dev`, `app-prod`, `database`, `metrics`, `websocket`, `gateway`, and `vpn`. Add `ingress` and `analytics` with [`node:role:add`](#orbit-noderoleadd). A Cluster Router comes from [`cluster:router:set`](/docs/cli/cluster#orbit-clusterrouterset).

```bash theme={"dark"}
orbit node:add beast beast.example.com \
  --host-key-fingerprint=SHA256:... \
  --role=app-dev \
  --tld=test \
  --setting=apps.path:/srv/orbit/apps

orbit node:add beast --role=app-dev --role=database
```

For macOS, use `node:add` with `--platform=macos` and set `--user` and `--orbit-user` to the same existing account. The machine must already join WireGuard and accept the Gateway's SSH key. No role is required. A role request fails with `node.platform_unsupported` before SSH. This path also enrolls an existing roleless peer whose SSH identity is not pinned, and it can correct that peer's platform and architecture from the machine. See [macOS Nodes](/docs/reference/node-provisioning#macos-nodes).

## orbit node:list

List every Node with its status, roles, and addresses.

```bash theme={"dark"}
orbit node:list [--json]
```

## orbit node:show

Show one Node, with its architecture, WireGuard and LAN addresses, roles, and apps path. JSON includes the stored `settings` and `excluded_projects`.

```bash theme={"dark"}
orbit node:show <node> [--json]
```

## orbit node:metrics

Show one metrics snapshot of an active Node: CPU per core, memory, swap, load, uptime, pressure, and disks.

```bash theme={"dark"}
orbit node:metrics <node> [--json]
```

The Gateway reads the snapshot from the [Metrics role](/docs/reference/metrics#read-node-metrics) through Grafana. It runs four queries for the Node's exporter: one for memory, swap, load, and uptime, and one each for cores, pressure, and disks. Only the first decides whether the Node has metrics. The Gateway stores no sample.

| Field | Meaning |
| - | - |
| `cores` | The busy ratio of each core, from 0 to 1. |
| `memory`, `swap` | Used and total bytes. |
| `load` | The 1, 5, and 15-minute load averages. |
| `uptime_seconds` | Time since boot. |
| `pressure` | The `some` `avg10` value for CPU, memory, and I/O. `0` when the kernel has no pressure data. |
| `disks` | Mount, used bytes, and total bytes. Pseudo filesystems such as `tmpfs` and `proc` are left out. `/` comes first. |

Human output shows bytes and durations in readable units. JSON keeps the raw numbers.

| Code | HTTP | Meaning |
| - | - | - |
| `node.metrics_unavailable` | 422 | The Node is not active or has no WireGuard address. |
| `metrics.assignment_missing` | 409 | No Node has the Metrics role. |
| `node.metrics_unreachable` | 502 | Grafana could not answer, or Prometheus has no samples for the Node yet. |

## orbit node:converge

Re-apply the Gateway-rendered Orbit footprint of a Node: its agent files, Caddyfile, private-DNS listener on the `vpn` Node, ProxyCli collector, and annotator server files. The fleet rollout runs the same step on each Node. It never changes an Instance or a role, so it works on a Node that owns Instances, unlike `node:add`.

```bash theme={"dark"}
orbit node:converge <node> [--force] [--json]
```

| Option | Meaning |
| - | - |
| `--force` | Re-apply every artifact, not only the ones whose digest changed. Each artifact still leaves a matching live copy alone |

The table lists each artifact as `applied` or `unchanged`, then the Node's footprint digest. When every digest matches the one the Node last received, the Gateway sends the Node no command. A Node that is not an active, managed Linux Node fails with `node.converge_unsupported`. [Converge the Orbit footprint](/docs/reference/node-provisioning#converge-the-orbit-footprint) lists the artifacts and error codes.

```bash theme={"dark"}
orbit node:converge beast
```

## orbit node:rename

Change the name of a Node. The name is only a registry identifier. Keys, addresses, SSH identity, and roles stay the same.

```bash theme={"dark"}
orbit node:rename <node> <name> [--json]
```

The new name uses ASCII letters, digits, dashes, and underscores, at most 63 characters, and must be unique. Repeating the current name succeeds and writes nothing. The Gateway holds the [lifecycle lock](/docs/reference/node-retarget#one-lifecycle-owner-per-node-name) for the current name and then for the new name, so a concurrent `node:add` cannot take the new name.

```bash theme={"dark"}
orbit node:rename gateway vpn
```

After a rename, callers must use the new name or the numeric ID. The WireGuard hub configuration names each peer in a comment. That comment keeps the old name until the Gateway next projects the hub, for example on the next `node:add` of any Node. [Relocate the gateway role](/docs/solutions/relocate-gateway-role) uses a rename to free the name `gateway` for a new machine.

## orbit node:settings

Set the apps root of a Node, the directory under which the Gateway creates development Instance checkouts.

```bash theme={"dark"}
orbit node:settings <node> --setting=apps.path:/mnt/apps [--json]
```

The command needs at least one `--setting`. It sends `{"apps":{"path":"/mnt/apps"}}` to the settings API, without a `settings` wrapper. An empty value unsets the path: `--setting=apps.path:` sends `{"apps":{"path":null}}`. The [API reference](/docs/api/overview) and [MCP](/docs/reference/mcp) publish the same nullable path inside a closed `apps` object. A change never moves an existing checkout. [Node settings](/docs/reference/node-settings#validation) lists the validation rules and failure codes.

## orbit node:wireguard-key

Set or replace the WireGuard public key of an existing unmanaged Node. Use this when the operator manages the peer's tunnel outside Orbit. The command does not generate a key pair, accept a private key, or configure the target machine.

```bash theme={"dark"}
orbit node:wireguard-key <node> <public-key> [--json]
```

| Argument or option | Meaning |
| - | - |
| `node` | Existing Node ID or exact name. No current-Node default. |
| `public-key` | Required canonical base64 encoding of exactly 32 bytes: 44 characters ending in `=`. Whitespace, malformed base64, an empty value, and null are invalid. The key must not belong to another Node or the WireGuard hub. There is no unset mode. |
| `--json` | Noninteractive output using the standard Node response and request ID. |

```bash theme={"dark"}
orbit node:wireguard-key laptop 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE='
```

A terminal prompts for an omitted Node with the shared Node data list labeled `Node`, then for an omitted key with the text prompt `WireGuard public key`, with no default. An invalid prompted key is requested again. With `--json` or without interactive input, missing arguments fail before mutation. Cancelling a prompt exits 1 and changes nothing. A supplied invalid key also fails before mutation.

The Gateway refuses a managed Node: a Linux or macOS Node with both a nonempty WireGuard address and a pinned SSH host fingerprint, regardless of its lifecycle status. It also refuses any Node holding the `gateway` role, regardless of that role's status. These guards cannot be overridden. The target needs an existing WireGuard address; the command does not allocate one or change the Node's status, roles, endpoint, address, or SSH identity.

WireGuard identifies peers by public key, not by Node ID. Before changing anything, the Gateway rejects a key assigned to any other Node, regardless of that Node's status or the caller's access to it, or equal to the hub's public key, with `node.wireguard_key_conflict` (HTTP 409). The collision check and assignment are atomic across concurrent key assignments; the target's name lock alone is not sufficient. The refusal leaves the stored key and hub unchanged and does not expose the other Node's identity. Supply the public key of a distinct key pair configured on the target, rather than removing or changing another peer. The target's own current key is allowed unless it also collides with another Node or the hub, so valid identical-key retries remain supported.

The Gateway stores the public key and reconverges the WireGuard hub on the `vpn` Node, using the existing [hub projection](/docs/reference/node-provisioning#wireguard-hub). It does not converge the target Node's footprint, contact it over SSH, or alter its host tunnel. Sending the same key still reconverges the hub, so a retry repairs a failed projection. Replacing the key can interrupt the target's connectivity until its locally configured key pair matches; no `--force` option or confirmation is required because this is an explicit setting operation, not Node removal.

The request is synchronous. Human progress uses `Set Node WireGuard key`, `Setting Node WireGuard key`, and `Set Node WireGuard key` for pending, active, and success, without invented remote stages. Success prints `WireGuard public key set for Node #<id> [<name>].` and `Request ID: <uuid>`. JSON emits only the standard Node response, including `wireguard_public_key`; errors use the standard error envelope. Success exits 0; validation, refusal, and projection failures exit 1. A lost connection or an interruption after sending the request does not prove the Gateway rolled back or completed publication. Activity by request ID helps correlate the attempt, but does not establish which key the hub is using.

Every authorized attempt records Activity as `node:wireguard-key`, with the caller, target Node, outcome, and request ID, but never the request body or private key material. A hub failure is not success: the newly stored key remains, the error identifies the failed projection step, and the operator retries the same command after fixing the hub. If the Gateway crashes after saving the key but before hub publication, the registry can contain the new key while the hub still uses the old configuration. If it crashes after publication but before recording completion, the hub can already use the new key while Activity later reports `activity.interrupted`. Activity alone cannot distinguish these windows. Do not assume rollback or automatic reconciliation. Restore an authorized management path to the Gateway, using another active peer with access to the target if the target's tunnel is unavailable, then replay the same desired public key. The replay checks collisions again and republishes the hub even when the stored key already matches. Configure the unmanaged target's local key pair yourself; replay never repairs that tunnel for you.

A successful projection preserves every non-target peer's public key, allowed addresses, and other peer settings. The [API entry](/docs/api/overview#set-an-unmanaged-nodes-wireguard-public-key) defines the request, response, errors, and required collision, rendered-configuration, and injected-failure acceptance cases.

### WireGuard key errors

| Code | Meaning and recovery |
| - | - |
| `node.reference_required` | The CLI got no Node in noninteractive or JSON mode. Supply an ID or name. |
| `node.wireguard_key_required` | The CLI got no public key in noninteractive or JSON mode. Supply the public key. |
| `validation.failed` | The key is not canonical base64 for 32 bytes, or the API body is invalid. Nothing changes. |
| `node.wireguard_key_managed` | The target has verified SSH management. Nothing changes; use the managed Node's normal provisioning/convergence path instead. |
| `node.wireguard_key_gateway` | The target holds `gateway`. Nothing changes; Gateway key changes are outside this command. |
| `node.wireguard_key_conflict` | The key belongs to another Node or the hub, including a concurrent assignment. Nothing changes. Use a distinct key pair on the target; do not alter the other peer. |
| `node.wireguard_ip_required` | The target has no WireGuard address. Nothing changes; enroll the Node with an address first. |
| `node.provisioning_busy` | Another lifecycle operation owns this Node's name lock. Nothing changes; retry when it finishes. |
| `activity.interrupted` | Activity reports a lost Gateway request, not the live hub state. Restore an authorized management path and replay the same desired key; neither rollback nor automatic reconciliation is guaranteed. |
| `vpn.configuration_invalid` | The hub settings are missing or invalid. The stored key remains; repair the VPN configuration and retry. |
| `vpn.server_lock_failed` | The hub projection lock could not be acquired. The stored key remains; fix the lock failure and retry. |
| `vpn.server_config_invalid` | The rendered hub configuration failed validation. The stored key remains; fix the configuration and retry. |
| `vpn.server_config_install_failed` | The hub configuration could not be installed, including when the remote `vpn` Node cannot be reached over SSH. The stored key remains; restore the hub management path and retry. |
| `vpn.server_start_failed` | The hub tunnel could not be activated. The stored key remains; fix the hub service and retry. |

The API also uses the shared identity, access, and not-found errors described in [Using the API](/docs/api/overview#read-an-error).

## orbit node:role:add

Add one role to a Node, or converge an existing role again.

```bash theme={"dark"}
orbit node:role:add <node> <role> [--converge] [--postgres-process=ID] [--clickhouse-process=ID] [--json]
```

| Option | Meaning |
| - | - |
| `--converge` | Converge an existing role again. It works for a role that is `active`, `failed` at a `converge:` step, or `provisioning` for more than 11 minutes with no operation holding the Node's role lock. |
| `--postgres-process=ID` | For `analytics` only: the PostgreSQL 16 Process that holds the Plausible accounts. |
| `--clickhouse-process=ID` | For `analytics` only: the ClickHouse Process that holds the events. |

```bash theme={"dark"}
orbit node:role:add beast database
orbit node:role:add beast metrics --converge
orbit node:role:add services analytics --postgres-process=41 --clickhouse-process=42
```

`vpn` and `router` fail with `validation.failed` and `Role [ROLE] is protected from generic mutation.` A role that conflicts with one the Node holds fails with `validation.failed` before anything changes. [Role compatibility](/docs/reference/node-provisioning#role-compatibility) lists the pairs. For `analytics`, a terminal lists the Docker Processes that run the right image and asks for each missing option. With `--json` or without a terminal, the command refuses. The [Analytics role](/docs/reference/analytics) page describes that role.

Role operations on one Node run one at a time. A command that waits more than 2 minutes fails with `node_role.convergence_failed`, `details.step` `converge:node-lock`, and `details.error_code` `node_role.node_busy`. Run it again.

Some steps report a failure without failing the role. The role stays `active`, and `follow_up` in the response names the failure and the retry. The CLI prints it as a warning. The `gateway` role's [private DNS route](/docs/reference/private-dns#the-gateway-machine) reports this way.

Adding `gateway` grants the new Gateway Node access to the active `vpn` and `metrics` Nodes, when they are other Nodes. Prefer [`node:role:relocate`](#orbit-noderolerelocate) to move `gateway`, `websocket`, or `metrics`.

## orbit node:role:list

List the roles of one Node with their status and failed step.

```bash theme={"dark"}
orbit node:role:list <node> [--json]
```

## orbit node:role:relocate

Move `gateway`, `websocket`, or `metrics` from its current Node to another Node. One assignment stays active for the whole request.

```bash theme={"dark"}
orbit node:role:relocate <node> <role> [--from=NODE] [--force] [--json]
```

| Argument or option | Meaning |
| - | - |
| `node` | Target Node ID or name. It must be active and hold no conflicting role. |
| `role` | `gateway`, `websocket`, or `metrics`. |
| `--from=NODE` | Source Node. Omit it to use the current holder. When the target already holds the role, `--from` names the Node whose leftovers to remove. |
| `--force` | Confirm the move. Required without a terminal and with `--json`. |

```bash theme={"dark"}
orbit node:role:relocate beast gateway --force
orbit node:role:relocate services websocket --from=beast --force
```

For `gateway`, the Gateway opens the `orbit:gateway-https` firewall rule on the target and moves the assignment. It grants the new Gateway Node access to the `vpn` and `metrics` Nodes, publishes private DNS on the `vpn` Node, and adds the [private DNS route](/docs/reference/private-dns#the-gateway-machine) on the target. It then removes that route and the firewall rule from the source. A failed route on the target reports `follow_up` and does not stop the move.

Relocating `gateway` does not move `vpn`, the Gateway checkout, its SQLite database, the Orbit CA, or the SSH keys. It does not stop Caddy or PHP-FPM on the source, so clients that still use the old address keep working. [Relocate the gateway role](/docs/solutions/relocate-gateway-role) gives the full order.

For `websocket` and `metrics`, the Gateway copies role credentials that the target lacks, without overwriting any. It moves the assignment, converges the target, and removes the runtime and publication from the source.

When a step on the target or the source fails, the role already runs on the target. The command fails with its usual code, `details.step` set to `converge:STEP` or `remove:STEP`, and `details.error_code` set to the step's own code. The message names the `--from` command that finishes the move. The Gateway reconciles Metrics once, after the source is done. A failed reconcile does not fail the move: `follow_up` names the Metrics Node and the `orbit node:role:add <metrics-node> metrics --converge` command.

## orbit node:role:remove

Remove one role from a Node.

```bash theme={"dark"}
orbit node:role:remove <node> <role> [--force] [--purge-data] [--offline] [--json]
```

| Option | Meaning |
| - | - |
| `--force` | Confirm the removal and its dependent cleanup. |
| `--purge-data` | Also delete role data. For `metrics`, the Prometheus and Grafana volumes and the stored credentials. For `websocket`, the Reverb checkout. |
| `--offline` | Remove the role from a Node the Gateway cannot reach. |

```bash theme={"dark"}
orbit node:role:remove beast database --force
```

`app-dev` cannot be removed while the Node owns Instances, and no role can be removed while a Route depends on it. Removing the last role restores public SSH first, as [Public SSH](/docs/reference/node-provisioning#public-ssh) describes.

The command removes a role that is `active` or `failed`. Each removal step is safe to repeat over what a failed convergence left. A role that is `provisioning` or `removing` is refused until its claim is stale. A failed removal leaves the role `failed` at `remove:STEP` and returns the step's own code in `details.error_code`. Run the same command again.

When two removals of one role run together, the second waits for the role lock. If the first finished meanwhile, the second fails with `node_role.already_removed` (HTTP 409). A role the Node never had returns `http.404`, except `ingress`: a confirmed removal of an `ingress` role that the Node does not hold succeeds and changes nothing.

## orbit node:access:add

Grant one Node access to act on another Node through the Gateway. An access grant authorizes Gateway commands. It does not change network reachability, which WireGuard membership already gives.

```bash theme={"dark"}
orbit node:access:add <consumer> <serving> [--json]
```

| Argument | Meaning |
| - | - |
| `consumer` | Numeric ID of the Node that sends commands. |
| `serving` | Numeric ID of the Node that the commands act on. |

Both Nodes must be active. Adding a grant that exists reports that it already exists. A [task VM](/docs/reference/compute-drivers#placement-invariant) Node never consumes a grant: the command fails with `task_vm.access_refused`.

A grant to the active `gateway` Node lets the consumer act on every Node. The `gateway` Node itself, and the machine that the Gateway bootstrap recorded as its serving host, act on every Node without a grant.

## orbit node:access:remove

Remove one access grant.

```bash theme={"dark"}
orbit node:access:remove <consumer> <serving> [--force] [--json]
```

Removing a grant to the Gateway Node also revokes Grafana access that depends on it. The CLI warns when you removed your own access to the Gateway Node.

## orbit node:remove

Remove a Node record and its Gateway configuration. The Gateway restores public SSH on the machine, so you can add it again.

```bash theme={"dark"}
orbit node:remove <node> [--force] [--offline] [--json]
```

| Option | Meaning |
| - | - |
| `--force` | Confirm the removal without a prompt. Required without a terminal and with `--json`. |
| `--offline` | Remove a Node that the Gateway cannot reach. The Gateway probes the Node first, and a Node that answers keeps every guard. A Node that does not answer still needs no Instances, Routes, Schedules, or firewall rules. |

```bash theme={"dark"}
orbit node:remove 7
orbit node:remove 7 --offline --force
```

The Gateway refuses while the Node owns Instances, Routes, Schedules, roles, Processes, or firewall rules. It never removes the caller's Node, the `gateway` Node, or the `vpn` Node.

<Warning>
  Use `--offline` when the Node does not answer. The Gateway probes first. A Node that answers follows online removal, skips `firewall-recovery`, and a Linux response has an empty `retained_on_node`. A Node that does not answer is not changed on the machine. `retained_on_node` then lists the Node agent, the Metrics exporter, and each shed role's leftovers. macOS always lists `user`, `package-managers`, and `host-wireguard`.
</Warning>

Removing a macOS Node also reports what stays on the machine. JSON includes `retained_on_node`, and the human output lists the same values: `user`, `package-managers`, and `host-wireguard`. Those are the account, the package managers, and the host WireGuard configuration. Declining the confirmation, or omitting `--force` without a terminal, cancels before the Gateway removes anything.

[Remove a Node](/docs/reference/node-provisioning#remove-a-node) lists each step and failure code.

## orbit node:excluded-project:add

Keep a Project off this `app-dev` Node for development placement. The Node must have an active `app-dev` role. `project:excluded-node:add` writes the same exclusion from the Project side. [Development node exclusions](/docs/reference/development-node-exclusions) owns the rules.

```bash theme={"dark"}
orbit node:excluded-project:add <project> --node=NODE [--json]
orbit node:excluded-project:add 4 --node=sabre
```

| Argument or option | Meaning |
| - | - |
| `project` | Numeric Project ID. |
| `--node=NODE` | Node ID or name. |

In a terminal, an omitted Project or Node opens a searchable selector. JSON and noninteractive calls must name both.

## orbit node:excluded-project:list

List the Projects that cannot use the Node for development.

```bash theme={"dark"}
orbit node:excluded-project:list --node=NODE [--json]
```

## orbit node:excluded-project:remove

Let the Project use the Node again. It asks for no confirmation, because it moves and stops nothing.

```bash theme={"dark"}
orbit node:excluded-project:remove <project> --node=NODE [--json]
```

## Why it works this way

These reasons explain the design. Check them before you propose a change.

### A dedicated relocate for gateway

The Gateway process serves the API. Removing the last `gateway` assignment and adding it again would drop the Gateway's authority and the `gateway.orbit` DNS record in between, so the second request might not run. Relocate keeps one active assignment for the whole move. Two active `gateway` assignments are rejected, because the fleet needs one authority and one address. The machine that runs the Gateway process keeps its authority during a move, because the bootstrap records it as the serving host.

### One relocate action for every relocatable role

Each role definition states whether the role is relocatable. Orbit does not infer it from singleton and mutable, because generic add and remove is a wider contract than a move. `gateway`, `websocket`, and `metrics` are relocatable. One relocate action runs the shared steps for all three: the singleton lock, the source check, the settings copy, and the assignment transfer. Role-specific work runs as a step for that role inside the same action. A new relocatable role adds the flag and its step, not a new command.

### The target's credentials win

A target that already holds working role credentials keeps them. Relocate copies only the credentials the target lacks, because overwriting a working identity would break its clients.

### Relocate moves no secrets

The checkout, the SQLite database, the Orbit CA, and the SSH keys live on the machine that runs the Gateway process. Copying them inside relocate would turn it into a secret-moving installer. You copy that state yourself and then converge the Gateway web stack on the new machine.

### vpn does not move

The WireGuard hub and private DNS stay on their original Node. `vpn` is protected from `node:role:add`, `node:role:remove`, and relocate.

### Rename is its own verb

A rename changes one identity field and does not touch WireGuard. Removing and adding the Node again would delete its WireGuard peer and records. `node:update` is rejected as a name, because update means a partial field patch.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.