Instance transfer
A transfer moves one active development Instance to anotherapp-dev Node. The Instance keeps its ID, Project, and Processes. A Schedule cannot target the Instance during transfer. The source gets a short downtime, and Orbit deletes the old placement at the end.
Request
The CLI callsPOST /api/v1/instances/{instance}/transfer.
The request accepts no path, Cluster, Route, or Process input. The caller needs an access grant to both Nodes. Interactive calls ask for default-No consent that names the source, the destination, the downtime, and the deletion.
--force gives that consent. JSON and noninteractive calls without --force return instance.confirmation_required.
Eligibility
Both Nodes must be active Linux Nodes with an activeapp-dev role, and each must belong to an active Cluster. The two Clusters may differ. The Instance must be an active development Instance with one authoritative Route, and not in removal. The destination must be another Node that the Project does not exclude. No Schedule may target the Instance. Orbit checks before reserving and again under the Process admission lock immediately before cutover, so a Schedule created during a transfer also prevents cutover.
Each other Route with a web root of the Instance must be active and must not be in a domain or placement change; otherwise transfer returns instance.lifecycle_conflict. A public Route with a web root cannot move to another Cluster and returns instance.transfer_public_web_root_route.
The Gateway reserves <destination-apps-root>/<project-slug>/<name>. It refuses an occupied or unsafe path with instance.destination_exists. Retry with another --name. A name that another Instance of the Project uses returns instance.identity_conflict.
What moves
The destination gets an independent checkout, even when the source is a worktree. It holds the branch, commits, detached state, tracked changes, untracked files, and file modes of the source. Orbit does not fetch, reset, clean, or push the source. The common repository, sibling worktrees, and local branches of a source worktree stay unchanged. Gateway Git commands that capture the source and prepare the destination pass-c core.hooksPath=/dev/null and -c core.fsmonitor=false. Checkout hooks and custom filesystem monitors are not transfer steps. The overrides apply to those commands without changing the stored Git configuration. Checkout commands that can start clean, smudge, or process filters run as the managed account without a credential environment.
The checkout and its .git travel as they are. Privileged file placement stays under the managed account.
Every transfer runs in this order:
- Orbit stops the source Processes and timers, with or without SQLite.
- It copies the source checkout to the destination.
- When you select a SQLite file, it takes one consistent snapshot and installs it at the selected path on the destination.
/tmp. A checkout of any size that fits the Gateway’s disk can move. If staging fails, the Gateway records and logs the failing step.
When the request selects a SQLite file, the checkout archive excludes that file and its -wal and -shm files. The SQLite step installs the consistent snapshot at the selected path, so the database contents come from the snapshot rather than an inconsistent archive copy.
Stopping Processes before the final checkout copy prevents their writes from being missed. The annotator preset also transfers its private store at /var/lib/orbit/annotator/instance-{id}. Capture adds the store to the checkout archive under .orbit/annotator; destination runtime installation restores it outside the checkout and removes that staging directory. Pausing the source unit does not delete its store. Source cleanup deletes the old store only after cutover, so rollback can restart the source with its annotations. Apart from that managed store and the selected SQLite file, Orbit copies no other database or data path.
Orbit imports the source .env into the stored environment. For a Laravel root of apps/site/public, that file is <source-checkout>/apps/site/.env; transfer closes its permissions and synchronizes the destination’s apps/site/.env, not an unrelated repository-root file. Transfer copies the entire checkout, keeps the effective web root, and relocates explicit Process working directories. A key that is already stored keeps its stored value. An unreadable source .env refuses the transfer. If a failure occurs before cutover, Orbit removes environment keys imported by that transfer along with the other prepared destination state.
A Docker Process with a null working-directory override keeps null on the destination and uses its image default; null is neither a source path to relocate nor a reason to skip runtime cleanup. An existing explicit override such as /app stays explicit unless it points inside the source checkout.
At cutover, every Process that has an environment file gets the destination .env in the application directory. This covers custom systemd Processes as well as presets such as vp-dev and annotator. Activation then rewrites each destination unit, so its EnvironmentFile= names the destination file, never the source Node’s path.
Process records keep their IDs, definitions, and desired states. Orbit stops their source units, creates them on the destination, and leaves no duplicate. The destination gets its own Vite port and SSR port, and Orbit releases the source ports after cleanup.
An assigned annotator port is reassigned under the destination Node lock at cutover. The source assignment stays in annotation_port_assignments until source Caddy retirement succeeds, including after a failed destination activation or interrupted cleanup. Another Instance cannot claim that port while the old proxy may still exist. Allocation skips both annotator and Agentation assignments on that Node. The destination units use the new port and Route domain, and the stored ANNOTATOR_URL placeholder stays unchanged. See Annotator Process.
Route
An explicit domain keeps its Route. The Route moves to the destination Node and Cluster. A generated domain uses the destination Cluster TLD:<project-slug>.<tld> for default, and <name>.<project-slug>.<tld> otherwise. When that domain is the same, the Route keeps its ID. When it changes, Orbit creates a replacement Route and releases the old domain after cleanup. The replacement keeps the web root of the Route it replaces. A domain that another Route owns returns route.domain_conflict.
Routes with a web root
The Instance’s other sites move with it. Each Route with a web root has an explicit domain, so it keeps its ID and domain and moves to the destination Node and Cluster at cutover. Transfer records these Route IDs. After cutover, Orbit first issues the destination workload and Router leaves of each such Route. Then it builds the Instance’s own Route and each other site: Caddy sites, PHP-FPM pools, LAN firewall rules, and DNS. Next it synchronizesAPP_URL in the .env of each other application directory on the destination. The checkout copy already holds those files. Only then does it start the destination Processes.
Cleanup removes each recorded Route’s workload leaf and firewall rule from the old workload, and its Router leaf from the old Router. A leaf that a site on that Node still loads stays, such as the Router leaf when the transfer kept the Cluster. A Route removed after cutover is still cleaned from the source.
Before cutover, transfer changes nothing for these Routes. Orbit checks them again under the cutover locks, so a site that became public or stopped being active during the transfer prevents cutover and rolls the transfer back.
A public Route with a web root cannot change Cluster, because transfer does not move a public edge. Make the Route private, transfer, and publish it again. Within one Cluster, the public edge stays where it is.
Failure and retry
Cutover is the moment the destination becomes authoritative.- A failure before cutover restarts the source Processes and keeps the source Route and every Route with a web root on the source.
- It also deletes the destination checkout, the destination Vite port, and a replacement Route that is not active yet.
- Keys imported into the stored environment are removed; keys that were already stored before the transfer remain.
- After cutover, recovery only goes forward. Orbit never restarts the source. It finishes the Route, runtime, and cleanup without copying the source again.
rollback_pending in recovery evidence. The marker keeps the transfer open if the Gateway stops during rollback. An identical retry finishes that rollback before pausing and capturing the source again; it never resumes the old forward checkpoint. The Gateway clears the marker and resets the checkpoint in one write only after rollback finishes. Recovery uses the same Instance and Node locks as transfer. It also clears the transfer’s reference to a replacement Route if rollback already deleted that Route.
Rollback abandons the transfer’s SQLite seed before it restarts the source. It removes the owned source snapshot, unfinished destination incoming and candidate files, and seed records. A retry takes a fresh snapshot after pausing the source again, so writes committed after restoration reach the destination. If seed cleanup fails or its response is lost, the Gateway records sqlite-seed as incomplete recovery evidence and keeps the transfer open. An identical retry must confirm that cleanup before it captures the source again. Cleanup checks the recorded operation and file identities; it never removes a file that belongs to another operation.
A transfer that fails before cutover is closed once rollback finishes. A new transfer request with any input, or instance:destroy, proceeds without a conflict from that closed transfer. Normal eligibility checks still apply.
An unfinished transfer, including a failed transfer whose rollback is incomplete, stays open. A transfer that fails after cutover also stays open. Only the identical request resumes an open transfer and closes it: a different transfer returns instance.transfer_retry_conflict, and removal returns instance.transfer_incomplete. For a pending transfer, the CLI offers the retry and names the original source Node. route:destroy also returns instance.transfer_incomplete for either Route while the transfer is open. This includes its destination replacement before cutover.
If a Schedule targets the Instance before reservation or is added before cutover, transfer returns schedule.target_in_use. Remove the Schedule or retarget it away from the Instance, then send a new transfer request if rollback has finished, or retry the identical request if the transfer is still open.
Orbit records the source Cluster’s Router on the transfer before cutover, and cleanup uses that record.
Cleanup deletes the old checkout or worktree and its runtime files, certificates, and firewall rules on the old workload and Router, including those of the recorded Routes with a web root. The result reports the destination Node, path, domain, and whether cleanup finished. It does not depend on an HTTP response from the application.