Skip to main content

Applications

A Project records one Git repository and the defaults for running it. An Instance is one copy of that Project on one Node. An Instance on an app-dev Node owns a checkout or a linked worktree. An Instance on an app-prod Node owns a production home with releases. The Project type decides whether an Instance gets a Route and serves PHP. This page follows an Instance from creation to removal. Each step links to the page that owns its details.

Create a development Instance

Select an active Linux Node with an active app-dev role. The name default is reserved for the Project’s default development source.
The Gateway clones the repository to <apps-root>/<project-slug>/<name>. It reads the repository with the Project’s source access. A gh_cli Project needs no GitHub login on the Node. The apps root comes from the Node settings. The name decides the path and the generated domain. The branch is a separate choice. For example, instance:create <project> <node> t3-1a2b3c4d --branch=t3code/1a2b3c4d creates that branch when it is missing. An origin branch keeps its existing selection behavior. An existing local branch is not reset to the default commit. A missing fetched default_branch when it is needed returns instance.branch_resolution_failed; Orbit selects no fallback branch. The implicit default selection must resolve the Project default branch. Invalid branch names return validation.failed before source changes. The response returns selected_branch and branch_override. branch_override holds the --branch value, also when it equals the default branch. It is null when the Instance inherits its branch. The Instance also records the starting commit. Creation moves through recorded states: reserved, checkout_prepared, source_resolved, and active. A failure before activation removes the new Instance and its owned checkout, Route and projections, runtime, staging paths, and database copies. Setup has not run, so cleanup runs no teardown and never cascades into another Instance. The response keeps the original failure code. Once cleanup completes, another create is a fresh request and can use a different branch. A fresh reservation records a unique source preparation ID before remote work. Preparation refuses an existing destination, creates the directory exclusively, and records the ID with its device and inode in Git metadata after cloning. Cleanup and retry require that receipt when a new reservation’s directory exists. A lost successful preparation response can be recovered from the receipt. An interruption before the receipt is written retains the unconfirmed directory and reports incomplete cleanup; retry and forced removal neither adopt nor delete it. Legacy reserved rows without preparation evidence cannot adopt existing source on retry. Matching origin and account ownership alone do not prove that an attempt owns a checkout. An interruption or incomplete cleanup can leave a pre-activation Instance. An identical retry resumes at the first unfinished state. The retry must name the same Project, Node, root, and branch override. A retry that changes one of them returns instance.placement_conflict. When cleanup cannot finish, the original error includes details.cleanup = "incomplete", the Instance identity, and a recovery command. An identical retry of a create whose cleanup started first finishes that cleanup, then creates the Instance afresh. Pre-activation removal accepts these states without weakening the source ownership guards. After activation, you can commit and move HEAD. The starting commit stays as it is. The recorded branch changes only through recording a renamed branch, or when a Project default-branch update switches a default Instance without branch_override. Keep the recorded branch checked out. Removal refuses a checkout on another branch with instance.source_branch_mismatch, also with --force. Cloning refuses such a candidate with instance.clone_candidate_branch_invalid. The Gateway refuses these requests before it changes anything:

Dependency copy

New Instances start from the checkout of default on the same Node, at the last commit that deployed there. Orbit creates a linked worktree at that commit. The Instances API exposes seed_path and seed_commit on default, so external callers can create their own linked worktrees at the same commit before registration. The Project’s setup steps own dependency copying. They receive ORBIT_SEED_PATH and ORBIT_SEED_COMMIT and can reflink all their dependency and cache folders, including nested monorepo projects. Copy only disposable folders that do not hold Instance-specific configuration, absolute links or runtime state. Do not copy .env, logs, databases, public/hot or configuration caches. A deployment of the default can change these folders while setup copies them, so keep the locked installs after the copy. A linked worktree shares Git history, never the seed’s working files. Use cp -a --reflink=auto for an ordinary-copy fallback on filesystems without block cloning, or --reflink=always when a Project requires shared blocks. When default has not deployed yet, Orbit creates an independent clone; the seed variables are empty and setup installs from lock files. Project setup steps replace the automatic copy of root vendor and node_modules.

Database clone

When the Project has an Instance named default with a database attached under prefix DB, every other new development Instance gets its own copy of that database. The copy has the same kind as the source. Orbit makes it after the source is ready and before the setup steps, so a setup step such as a migration runs against the copy. On the same Node, Orbit holds SQLite’s write lock while it takes a reflink of the SQLite database file and its -wal file. It first checks that the filesystem can clone the file, so no writer waits when it cannot. Orbit copies a snapshot made with SQLite’s backup API instead in three cases: the filesystem has no block cloning, a writer holds the lock for two seconds, or the source is on another Node. Orbit records the copy as a database the Instance owns with the connection slug <project>-<instance>, attaches it under prefix DB, and synchronizes .env and .env.testing. When the Instance has no stored configuration yet and its application directory has a .env, Orbit first imports that file, so synchronization keeps its other keys. Without a .env, synchronization writes only the stored keys. The clone returns these codes. No teardown step runs after a failed copy, because no setup step ran yet. When the removal cannot finish, the error has cleanup: incomplete and names the instance:destroy command that finishes it. Orbit records each finished step of the copy on its connection. When a create stops before the copy finished, an identical instance:create finishes the copy and then runs the setup steps. It copies the data again unless the earlier copy finished, so it never keeps a partial copy. Only a create that has not finished its first setup resumes this way. A retry keeps the Instance when the copy or a setup step fails again; only the request that activated the Instance removes it. The copy holds the full data of the default Instance, including personal data. A Project without a default Instance, or whose default Instance has no DB attachment, gets no copy.

Database on a server

An Instance that gets no copy, such as the default Instance itself, can get an empty database during create. Name a Database server with --database-server (API field database_server).
After the source is ready and before the setup steps, Orbit does what database:create --server --instance does. It creates the database <project>_<instance>, its test database, and the Instance’s user on the server, with the connection slug <project>-<instance>. It records the Instance as the owner and attaches the database under prefix DB. Then it imports and synchronizes .env and .env.testing as the clone does. A setup step such as a migration finds the database on the first create. The other database:create codes, such as database.name_conflict and database.slug_conflict, pass through. On the request that creates the Instance, every failure after the check removes the Instance and the database it owns, as a failed copy does, and no setup step runs. When a create stops before the database exists, an identical instance:create creates it and then runs the setup steps. That retry never removes the Instance: when the database or a setup step fails, the Instance remains and the error says so. --database-server applies only to an Instance that instance:create is still creating. For an Instance that finished its first setup, Orbit refuses the option with instance.database_server_existing and changes nothing, even when that Instance’s last instance:setup failed. Give it a database with database:create --server --instance, then run instance:setup. A repeat of a finished create whose Instance already owns a database on that server changes nothing and returns status 200. When that Instance’s last instance:setup failed, the repeat returns instance.setup_step_failed instead. The caller needs an access grant to the Instance’s Node and to the Gateway, the same access database:create needs.

Record a renamed branch

Rename the Git branch in the checkout first, then tell Orbit what is already checked out:
The API is POST /api/v1/instances/{instance}/rename with optional branch and domain strings; at least one nonempty value is required. The MCP tool is instance-rename. Send instance and at least one of branch or domain. All forms return the same Instance representation as instance:show. Only an active development checkout is eligible, not a linked worktree or production Instance. The Instance ID, name, path, layout, starting commit, and placement stay the same. For a supplied branch, Orbit reads the checkout locally on its Node. Symbolic HEAD must already name that exact branch, or the request returns instance.branch_not_checked_out without changing anything. Orbit checks source identity but never contacts origin, changes Git refs, switches the checkout, or renames the branch itself. Dirty or unpublished source is allowed for this recording operation. A changed branch becomes both selected_branch and branch_override. This pins even a default Instance to the explicit selection. Supplying the already recorded branch is a no-op that preserves the existing override. Domain-only rename leaves both branch fields alone. Removal, cloning, and Doctor then check the newly recorded branch; normal removal still requires clean, published source. A supplied domain moves the Instance’s own single-target Project Route through Route replacement, including a generated Route. Laravel’s stored APP_URL, .env, and cached config follow the new URL. All supplied fields and the domain’s availability are checked before mutation. A combined request with a wrong branch or occupied domain changes neither record. The branch record changes only after any requested domain convergence succeeds. An identical retry after a completed rename returns the Instance without creating an additional Route, including when the caller lost the success response. Incomplete Route replacements follow Route recovery. If a full rollback before cutover deleted the failed replacement, an identical retry can reserve a fresh replacement; the changed branch is still recorded only after domain convergence succeeds. Another lifecycle owner returns instance.lifecycle_busy, including contention from the environment owner. A different domain while replacement recovery is incomplete returns route.domain_change_conflict. This includes requesting the original domain before or after cutover: the retained replacement owns its destination, so the original domain is not a no-op. That refusal changes neither the recorded branch nor stored or application environment values. Infrastructure failure can leave Route recovery work before or after cutover, so inspect the Route and repeat the same request. instance:rename lists the inputs, output, and refusal codes. Development branch reconciliation explains the reasons and alternatives.

Register an existing checkout

Run registration on the app-dev Node that holds the source. The Node that sends the request is the Node that receives the Instance.
Registration transfers ownership of the source to Orbit. Orbit moves the source into its managed path, and instance:destroy later deletes it. There is no unregister command. The CLI reads the stored remote.origin.url of the checkout. It sends no request for a directory outside Git or for an unsafe origin. A safe origin is https:// without a user or password, ssh:// without a password, or git@host:path. Any other scheme, a query, a fragment, whitespace, or a control character is unsafe. The Gateway finds the Project by repository identity, or uses --project. Registration never creates a Project or infers a nested web root. It inherits the configured Project root unless --root supplies an explicit override. When no Project owns the repository, the Gateway returns instance.project_missing and changes nothing. Create the Project with project:create first. The Gateway then inspects the source on the caller’s Node. It trusts none of the facts the CLI sends. For a source that qualifies as default, a --name other than default returns instance.identity_conflict. A name must be a lowercase DNS label of at most 63 characters, or registration returns instance.name_invalid. Orbit records the source layout as checkout or worktree. It moves the complete source to <apps-root>/<project-slug>/<name>. HEAD, the branch or detached state, the index, dirty and untracked files, and all refs stay as they are. A source that is already at that path is adopted in place: Orbit verifies it without moving, copying, or repairing its Git links. Source verification covers this source’s HEAD, branch, index, working tree, and local Git configuration. It does not include other branches, tags, remote-tracking refs, or agent checkpoint refs in the shared repository. Those refs can change while registration runs or between attempts. A change to the source itself before adoption or during a move still refuses registration. An identical retry resumes when the source itself is unchanged. After adoption, the managed destination is authoritative and retries check its Git identity rather than the pre-adoption content digest. By default, registration adopts only the requested source. When Orbit moves a checkout that has linked worktrees, it repairs their Git links so they keep working. --include-worktrees adopts the checkout and every linked worktree in one step. The Gateway checks the complete set before it moves anything. If one check fails, nothing moves. A move to another filesystem copies and verifies the source before Orbit deletes the original. An identical retry resumes an interrupted move. The Gateway finishes that move during registration. It does not stop and ask you to change the source yourself. Interactive registration asks for default-No consent that names the source. JSON and noninteractive calls need --yes. --setup runs the Project setup steps after adoption. Plain registration runs no setup.

Provision the application endpoint

Before it prepares the source, the Gateway assigns the Instance a Vite port and an SSR port and reserves its Route domain. --domain sets an explicit domain. Otherwise the domain is generated from the Cluster or Node TLD, as Routes describes. A laravel-app or symfony-app Instance gets one Route. Other Project types get no Route. After the source is ready, the Gateway continues in this order:
  1. It inspects the source once. It records the PHP version and whether the source is Laravel. This pair is the source profile.
  2. For a Laravel source with a Route, it sets APP_URL to the Route URL. See Laravel application URL.
  3. It publishes the Route, certificates, Caddy site, firewall rules, and private DNS records.
  4. It marks the Instance and Route active.
  5. It runs the Project setup steps.
An Instance without a Route skips steps 2 and 3. A monorepo Instance without a Route also skips application metadata inspection: its checkout can contain several Composer projects, so Orbit records no PHP version and a false Laravel flag rather than treating the repository root as one application. It becomes active, runs setup, and stays healthy without a visitable endpoint. This includes the Orbit Project’s default Instance. An identical retry of a create stopped at source-classification resumes from the resolved source. For an Instance with a Route, a retry after step 1 inspects the source again. If the PHP version or the Laravel flag changed, the retry returns app-dev.source_evidence_changed. For an Instance without a Route, a retry records the new profile. A failed setup step during instance:create runs the teardown steps and removes the new Instance. See Run setup. An identical instance:create for an active Instance returns it unchanged and runs no setup. An Instance can stay active with a failed setup: after instance:setup or instance:register --setup fails, or when Orbit could not confirm the failed step or finish the rollback. Then instance:create returns instance.setup_step_failed until instance:setup succeeds, unless the database clone did not finish: then instance:create finishes it and runs setup. Orbit does not recover missing source profiles on older Instances. Projects: One public name without compatibility explains the no-legacy-support rule.

Laravel application URL

For a routed source, Orbit inspects the application directory derived from the effective web root. A nested Laravel app uses its own composer.json and artisan, not the repository-root files. Registration does not discover that root for you. For Project types other than laravel-package, Orbit treats a source as Laravel when it has a regular artisan file and a composer.json that declares laravel/framework exactly once, in require or require-dev. A source with a composer.json and neither marker is plain PHP. A source without composer.json has no PHP. A laravel-package Project is exempt from this application-marker validation: it needs no artisan, and a laravel/framework declaration alone is valid. Detection reads files only. It runs no Composer, Artisan, or application code, and it installs no dependencies.

Symfony applications

A symfony-app Project checks bin/console instead of artisan. Its source is valid when it has a regular bin/console file and a composer.json that declares symfony/framework-bundle in require. A composer.json with neither marker is plain PHP. bin/console without the declaration, the declaration without bin/console, or a symlinked bin/console returns app-dev.symfony_source_invalid. bin/console without composer.json returns app-dev.source_metadata_unsafe. A Symfony source is never Laravel. Orbit does not write APP_URL for it and does not force APP_URL in its stored environment. Set the values the application needs with env:update. For a Laravel source, Orbit writes APP_URL=https://<route-domain>:
  • When .env exists, Orbit replaces the one APP_URL line or adds it. It also fills an APP_KEY line that has an empty value. Every other byte stays the same.
  • When .env is missing, Orbit creates it from .env.example, or empty, and adds APP_URL. The new file also gets a usable APP_KEY and the Project’s APP_NAME.
  • When bootstrap/cache/config.php exists, Orbit replaces its one cached url value.
Laravel cannot boot without APP_KEY, and a fresh .env.example often has an empty key and APP_NAME=Laravel. So a new .env takes the Instance’s non-empty stored APP_KEY. Without one, it keeps a non-empty template key or gets a random 32-byte key with the base64: prefix. It takes the Instance’s stored APP_NAME too. Without one, a missing, empty, or Laravel name becomes the Project name. An existing .env keeps its name and any non-empty key. The key reaches the Node as protected input, never as a command argument. Orbit leaves duplicate APP_KEY or APP_NAME lines as they are. A symlinked file, two APP_URL lines, or an unclear cached value stops provisioning with app-dev.laravel_url_configuration_failed. After activation, the stored environment owns APP_URL.

Production Instances

instance:create refuses an app-prod Node with instance.candidate_required, including a repeat for a production Instance that already exists. A production Instance always starts as a clone of a development or production Instance. Its first deployment creates its first release. A Project can have one production Instance per app-prod Node. Each production Instance has its own Unix user, home, and PHP-FPM service.

Set the web root

An Instance inherits the Project root. --root stores a relative override for one Instance. The root is a relative path of at most 255 bytes. Each segment uses only A-Z, a-z, 0-9, ., _, and -, and is not . or ... A leading or trailing / or an empty segment is refused. A package Project also accepts ., the repository root. On a production Instance, the root resolves inside the selected release, through <home>/current.

Input boundary

instance:create and instance:destroy accept no repository, command, or shell input. The Project owns the repository. Registration accepts source facts only, and the Gateway verifies each one. It never accepts a Node: the caller is the Node.

Why it works this way

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

Registration takes ownership

A registered source enters the same lifecycle as a created checkout: one managed path, one removal command, one set of safety checks. Registration that only observes an external worktree was rejected. It needed a separate unregister command, could not move or delete the source, and left Orbit serving paths it did not control. Adopting only linked worktrees was rejected too, because an independent checkout is equally useful source.

The default Instance keeps its name

The default development source keeps the name default, its path, and its Route when the Project’s default branch changes. An Instance named after its branch would move and get a new domain on every branch rename. A second public name for the default branch, such as main_branch, was rejected because two names for one setting make the contract unclear.

An explicit branch stays explicit

Orbit records --branch even when it equals the default branch. Comparing values later cannot tell a deliberate choice from an inherited one. So when the Project default branch changes, Orbit switches only the development Instance named default, and only when it has no override. Using the Instance name as the only way to pick a branch was rejected, because a release branch would then need a new identity.

Development branch reconciliation

An agent can start on a temporary branch such as t3code/1a2b3c4d, rename it locally after its first message, then record t3code/login-redirect and a readable URL. Branch names are independent of Instance identity: they can contain /, but Instance names decide managed paths and generated domains. Requiring branch and Instance names to match was rejected. Selecting a different existing branch as a fallback was rejected too, because it silently gives the agent the wrong source. Orbit creates only a genuinely missing requested branch from the fetched default commit. Branch recording is explicit and checks the checkout without changing it. Inferring a rename in Doctor or removal would mutate intent and hide a checkout switch. Running git branch -m inside Orbit would repeat the caller’s operation and introduce races. Renaming the Instance or moving its directory would disrupt agents and Processes without helping branch reconciliation. Force still cannot waive a resolved source’s branch identity check: it permits discarding dirty or unpublished work, not deleting a checkout with a different identity. Recording an unpublished branch does not make its commits published for normal removal. Failed creation should release its name, path and domain, not leave every attempt for manual deletion. Cleanup therefore removes confirmed attempt-owned resources while retaining recovery state when it cannot finish. Origin and account ownership are insufficient evidence: an unregistered same-origin checkout may contain another caller’s work. The preparation receipt binds a reservation to the directory it created, so retries and force cannot claim an existing or replacement checkout. An interruption before that receipt leaves source intact rather than guessing ownership. An Instance-owned domain change uses the existing Route replacement path rather than another Route endpoint or an in-place domain edit. That path already owns Caddy, certificates, DNS, Laravel URL synchronization and retries. A generated Route keeps its provenance and generation basis; converting it to explicit provenance would erase how it originated and change later slug and TLD recomputation. Its supplied readable domain can therefore change again when those inputs change. Infrastructure cannot share a database transaction with source records, so Orbit validates first and records a changed branch only after requested domain convergence succeeds. Promising atomic rollback after cutover was rejected: recovery before cutover restores the old domain, while recovery after cutover completes the change.

Active does not mean healthy

An Instance is active once Orbit prepared its source, runtime, Route, and Laravel URL. A new application can lack dependencies or a database, and it can return errors. You need the endpoint to finish that setup. So Orbit does not wait for a healthy response. A health gate, a separate activation command, and setup commands inferred from the framework were rejected.

Orbit owns the Laravel URL

Laravel uses APP_URL to build links outside a request. When Orbit changes a domain and leaves APP_URL alone, links break. So Orbit derives APP_URL from the Route in development and production. Reading an existing APP_URL as the source of the domain was rejected: the Route decides the endpoint.

Create the database before setup

A database attached with database:create --instance can only come after the Instance exists. By then instance:create has run the setup steps, so a migration step failed and rolled the whole create back. Orbit now creates the database inside the create, between activation and setup, by reusing database:create. Ignoring a failed setup step was rejected, because an Instance whose setup failed is not a useful result. A default server stored on the Project was rejected, because the choice belongs to each Instance and a second hidden default would compete with the database clone. A create retry resumes only an Instance that carries the create’s own mark, never one inferred from failed_step: setup. That value also follows a failed instance:setup on an Instance that has been live for weeks, and a retry that treated it as an unfinished create removed that Instance when the database or setup failed. For the same reason, an existing Instance gets its database from database:create --instance, and a retry never removes an Instance that an earlier request activated.

Copy dependencies, not the checkout

A Project knows its own dependency and cache folders. Copying only the root missed the dependency trees of nested projects. Project setup steps can copy the matching folders from the default checkout and then install incrementally. Copying a whole checkout is rejected because it brings Instance-specific configuration and runtime state. A ZFS dataset clone per Instance is rejected because it needs per-Instance datasets and privileges; ordinary reflinks work within the existing storage layout.

One application model

The Instance is the only runnable application model. Orbit keeps no conversion tooling and no compatibility path for other models. Two models would double every lifecycle rule and every check.