Skip to main content

Projects

A Project records one Git repository and the defaults for running it. New Instances inherit its default branch and root. Its type decides what those Instances can do. The API path is /api/v1/projects, and the CLI family is project.

Project Documents

Project Documents are a native folder tree of notes and versioned attachments owned by the Project, independent of its Instances and Git branches. Orbit keeps their metadata in the Gateway database and their bodies in a dedicated private UpCloud bucket. The shared contract covers editing, upload/download, archive, permanent removal, and recovery across API, CLI, SDK, MCP, and web.

Fields

A Project stores these fields. API responses, the SDK, and CLI JSON use the same names.

Application directory

For a Laravel Instance, the application directory is the effective web root without its trailing /public segment. The effective root is the Instance’s override when present, otherwise the Project’s root. public means the checkout root in development, or the release root in production. apps/site/public means apps/site inside that checkout or release. One shared helper derives the directory for Laravel source inspection and runtime consumers; Orbit does not store a second directory setting. The application directory contains composer.json, artisan, development environment files, and Laravel logs. PHP-FPM, default systemd Instance Processes, and Instance Schedules use it as their application working directory. Production resolves it through current. Setup, teardown, deploy steps, and task-check commands still run from the repository root; a nested Artisan step must change directory explicitly. Setup and teardown and task checks export VP_HOME to the Node’s resolved Vite+ store, including for project-local vp. Registration never infers a nested root from source files. Configure the Project’s root, or send an explicit Instance root override. An Instance can serve more directories of its checkout or release through Routes with a web root. Each one derives its application directory with the same rule and gets its own PHP-FPM pool and APP_URL.

Setup and teardown steps

A Project owns ordered setup and teardown lists for its development Instances. Each named command runs on the Instance’s Node from the repository root. A setup step can also name a Gateway-owned operation, such as @orbit/ci-caches, which the Gateway renders before the step runs. Production Instances run neither list. When a command is missing or not executable on that Node (exit 127 or 126), Orbit returns instance.setup_step_unavailable or instance.teardown_step_unavailable with the step name and outcome: missing. The message names the step, Node, and exit code and says the command was not found or is not executable. Other command failures still return instance.setup_step_failed or instance.teardown_step_failed. See Instance setup and teardown for retry and removal behavior.

Development deploy steps

A Project owns an ordered development deploy list, separate from setup and teardown and from production’s per-Instance deploy steps. Use project:dev-deploy-step to list, create, update, or remove a step. Each step stores a name, command, timeout, and required boolean, which defaults to true. These operations change configuration only; they never start a deployment.

Project types

The type belongs to the Project, so every Instance of one repository behaves the same way. . means the repository root. A Route cannot target an Instance whose root is .. Set a relative web root first. A laravel-package Project does not need an artisan file. A symfony-app serves like a laravel-app: one Route, a PHP-FPM pool, and the PHP version from composer.json. Orbit runs no Laravel step for it. It writes no APP_URL and patches no Laravel configuration cache. See Symfony applications.

Create a Project

Create a Project before you create or register its first Instance.
The CLI root defaults to . for package types and public for other types. The API and SDK require root. Without --default-branch, the Gateway reads the remote default branch once and stores it. A later change on the remote does not update the Project. An explicit branch must exist on the remote. The Gateway reads the remote with the Project’s source access. A Project’s development default deploys in its checkout. Its setup steps can use that checkout as a dependency seed for other development Instances on the same Node. Creation is idempotent. A retry with the same values returns the existing Project. A retry that omits the default branch does not read the remote again. A retry with any different value, including another URL for the same repository, returns project.identity_conflict and changes nothing.

Source access

source_access decides how Orbit reads a private github.com repository. It applies to every read: default-branch checks on create and update, branch resolution on instance:create, and every clone and fetch on a Node. A public repository and a repository on another host work with github_app. A gh_cli Project cannot start tasks, because tasks publish through the App.

Repository identity

The Gateway derives a repository identity from the host and path of the URL. Equivalent SSH and HTTPS URLs, with or without .git, have the same identity. A second Project for the same repository returns project.repository_identity_conflict. Registration uses this identity to find the Project of a checkout. Doctor uses it to compare a checkout’s origin with the Project, so an SSH origin of an HTTPS Project is not project.repository_origin_mismatch.

Registration needs a Project

instance:register adopts a checkout only for an existing Project. It finds the Project by repository identity, or uses --project. When no Project owns the repository, it fails with instance.project_missing and changes nothing. Create the Project with project:create first. Registration inherits its root unless an explicit Instance override is sent; it does not search the checkout for nested apps. SDK Project responses and the project:list and project:show commands expose the stored type, repository, source access, default branch, root, task check, task_workspace_routed, review_and_merge, and merge_check. The task check is an ordinary setting, like setup steps, so activity records it as sent. The API, SDK, CLI, activity, Doctor, and validation contracts expose no main_branch or --main-branch compatibility name.

Retry creation safely

Repeating project:create with the same name, slug, type, repository access URL, source access, default branch, root, any sent task check, and any sent task_workspace_routed value returns the existing Project. An omitted source access means github_app. Omitting task_workspace_routed keeps the stored value. A retry does not look up an omitted branch again. A retry that changes any creation value fails with project.identity_conflict and does not mutate the Project. A different repository access URL is a changed value even when it has the same canonical repository identity, so creation never switches the stored URL.

Project codes

Each Project has a unique code of three uppercase letters. The Gateway derives it from the slug unless POST /api/v1/projects sends code. The code stays the same when the slug changes. Task cards use it as a label. Change the code in the web app, or send PATCH /api/v1/projects/{project} with only code. A code sent with other fields returns project.code_update_separate. A code that is not three uppercase letters returns project.invalid_code. A code in use returns project.code_conflict. When every three-letter code is taken, creation returns project.codes_exhausted.

Update a Project

Use project:update when an existing Project must change its display name, type, slug, repository access URL, source access, default branch, relative web root, task check, task workspace routing, or review and merge. The Gateway API accepts PATCH /api/v1/projects/{project} with those same fields, including task_workspace_routed. The PHP SDK sends UpdateProjectRequest to that path. Omitted fields stay unchanged; send task_check: null to clear the task check. The CLI and the MCP project-update tool accept the same fields. The Update lifecycle defines source reconciliation. The API, SDK, CLI, activity, Doctor, and validation contracts expose no main_branch or --main-branch name.
project:update and PATCH /api/v1/projects/{project} change the fields you send and leave the rest. Creation never updates a Project. A type change must keep a valid root. When the stored root is . and the new type does not allow it, validation fails on root. Send a web root with the type change. A type or root change that leaves a Route target with root . returns route.target_web_root_unsupported. A slug change does not move a development checkout. Orbit creates it at APPS_ROOT/SLUG/NAME and stores that path. Deployment, source resolution, removal, and Doctor read the checkout’s directory from the stored path, not from the current slug. So a checkout created under the old slug keeps working after the change.

Change source access

The Gateway first resolves the remote default branch with the new source_access value. When that read fails, nothing changes. A default_branch or repository_url sent in the same request is checked with the new value.

Task workspace routing

POST /api/v1/projects and PATCH /api/v1/projects/{project} accept task_workspace_routed as a JSON boolean. Null, strings, and numbers fail with HTTP 422 validation.failed, with details for that field. Creation defaults to true; an omitted update leaves it unchanged. An explicit value participates in creation’s identity check; omitting it on an identical retry preserves the existing value. API list and show responses, the SDK, CLI JSON, and MCP expose the stored boolean. CLI human detail output labels it Task workspace routed. The create and update commands accept --task-workspace-routed=true or --task-workspace-routed=false. An invalid CLI value returns project.task_workspace_routed_invalid before a request. The setting controls task provisioning only. It does not change ordinary Instances or bypass root, Route, and Project-type validation. A settings-only update does not reconcile existing sources or Routes. The migration seeds false for existing Projects with slug orbit and true for other existing Projects to preserve their previous creation behavior. This is a one-time migration of the legacy policy; the engine never consults the slug. It also records the mode of existing task workspaces from their actual provisioned state, so Doctor does not reinterpret them after a settings change. Renaming a Project does not change the setting.

Review and merge

PATCH /api/v1/projects/{project} accepts review_and_merge as a JSON boolean and merge_check as a string of at most 255 characters, or null. The flow is on only while review_and_merge is true, merge_check is set, and source_access is github_app. A request that would leave the switch on without one of these fails with HTTP 422 validation.failed on merge_check or review_and_merge, and changes nothing. CLI human detail output labels the fields Review and merge and Merge check. An invalid CLI value returns project.review_and_merge_invalid, project.merge_check_conflict, or project.merge_check_invalid before a request.
Tasks: Review and merge describes what the switch changes.

Update lifecycle

The Gateway applies slug, repository_url, default_branch, and root as one recorded operation: An update is published once a new value is visible: a replacement Route has cut over, or the Project record holds a new value. A failure before that rolls back, also during publishing. Orbit withdraws each replacement Route that never activated, renders each environment it synchronized against the old Route again, and restores origins, production homes, branches, and the Project record. The update then ends in rolled_back, and other updates can run. A retry after a fix starts again. A rollback that fails stays rolling_back, and an identical retry continues it. A failure after publication stays in place, and an identical retry continues forward. A different update while one is published but incomplete returns project.update_in_progress. An update holds the locks of every Instance of the Project while it runs, so an unpublished incomplete update that another request finds is not running: an update that stopped before this rule, or a rollback that failed. Orbit rolls it back first and then runs the new request. When that rollback fails, the request returns the rollback’s error and the old update stays rolling_back. A Project without Instances has no Instance lock. Its incomplete update requires an identical retry; a different request returns project.update_in_progress without rolling it back. Slug rollback withdraws only the recorded replacement or a fresh replacement for the same proposed domain. It leaves an unrelated domain change in place. A slug change synchronizes the stored environment of each development Instance that has one, against the replacement Route. An Instance without stored configuration has nothing to synchronize. Orbit skips that step and records environment: skipped with the reason env.configuration_missing in the update evidence; Route convergence still writes the new APP_URL into the .env of the application directory. See Project update boundary. When updating source, the Gateway passes -c core.hooksPath=/dev/null and -c core.fsmonitor=false to Git during preflight, origin changes, fetches, branch changes, and rollback. The Gateway does not run checkout hooks or a custom filesystem monitor for those operations. The overrides do not change the stored Git configuration. Branch switch, rollback, and origin changes run as the Node’s managed user, the owner of the checkout. Branch switch and rollback checkout commands write the work tree, so they run without a credential environment. A switch checks out with --no-track, and then records the branch’s upstream. Preflight fetches use the Project repository URL and never read the checkout’s configuration; see credentialed Git. Run Doctor to inspect any projection that needs attention.

Repository changes

A repository change touches only origin. Local branches, the checked-out commit, and the recorded starting commit stay the same. Orbit never pushes. Linked worktrees share the checkout’s repository and need no change. Every origin check reads the remote.origin.url stored in the checkout. It ignores insteadOf rewrites on the Node.

Production Instances

A production home records the repository URL in three places: the release layout marker and the initial clone marker under /var/lib/orbit/app-instance-sources/<instance>/, and remote.origin.url of each retained release. Deployment and rollback compare these values byte for byte with the Project’s repository_url. A repository change re-binds every production home on the release layout to the new URL. Preflight reads each home first. Every recorded URL must name the same repository as the new URL, in SSH or HTTPS form, with or without .git. Another repository fails with project.production_repository_mismatch, and nothing changes. Prepare then rewrites each value that differs, the releases first and the release layout marker last. Each rewrite is a compare-and-swap, so a retry continues safely. A partial release without an origin stays as it is. Rollback writes the recorded URLs back. A re-bind changes only those URLs. It never changes production source, the selected release, the deployment branch, or the recorded commit, and it never starts a deployment. A repository request also re-binds when the URL is already the Project’s URL. Use it to repair a home that an update before this rule left on the old URL. Deployment names that repair with deployment.repository_rebind_required.

Remove a Project

project:destroy removes a Project that has no Instances and no Routes. It deletes the Project’s process and Schedule definitions, task definitions, setup and teardown steps, Node exclusions, and update records. A Project with tasks cannot be removed. The Gateway refuses the request with HTTP 409 and project.has_task_groups.

Errors

The Gateway returns these codes for Project requests.

Why it works this way

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

One repository, one Project

Registration must find exactly one Project from a checkout’s origin. Comparing URLs as strings was rejected, because an SSH and an HTTPS URL would allow two Projects for one repository. Letting Projects share a repository and taking the first match was rejected, because the result would depend on database order.

One way to create a Project

project:create is the only way to create a Project, so every field, such as source access, is set in one place. Creating a Project during registration was rejected. That second path needed its own options, prompts, and conflict codes, and could not set every field. A private repository that needs the GitHub CLI failed there before the operator could choose the setting.

Updates are separate from creation

Creation stays idempotent: a changed value is a conflict, never an update. A slug, URL, branch, or root change touches checkouts, Routes, and runtimes on several Nodes, so it runs as one recorded operation. A plain database update would leave checkouts, domains, and document roots out of step.

Recovery goes forward after publication

Until a replacement Route cuts over or the Project record changes, the old values are still in effect, so a rollback is safe. After that point, clients can already see the new domain. Rolling back would change a public name twice. So a failure after publication is retried forward. Deciding by status alone was rejected: an update in publishing that failed before its first cutover kept the old domain live, yet blocked every other update and left its replacement Route and origin changes behind until someone repeated the exact request. An unpublished update that stopped is rolled back when a different request arrives, instead of returning project.update_in_progress. The Instance locks prove that it is not running, and rolling it back changes nothing that clients see. Refusing would leave the operator with no supported way out when the stopped request keeps failing.

Type decides capabilities

Instances of one repository share one serving contract. Per-Instance route or PHP-FPM flags were rejected. A Laravel package or a monorepo must not publish a domain or keep an idle PHP-FPM master, so only laravel-app and symfony-app get a Route by default.

A setting routes task workspaces

A new task workspace is visitable only when the Project’s task_workspace_routed setting says so. Choosing that from the slug orbit was rejected, because the engine would then know one repository. Task workspace routing records the one-time migration of that old result, and that a later change does not reroute a workspace that already exists.

One public name without compatibility

Project is the only public name for the repository record. Orbit has one operator, who does not value legacy support, so compatibility paths, aliases, inert endpoints, and conversion windows are removed by default without waiting for fleet migration or another confirmation. A second name adds code, tests, documentation, and ambiguity without protecting a supported user population. The supported surface is /api/v1/projects, project:*, the Project MCP tools, and Instance fields project_id and project. The former /api/v1/apps routes, their nested Process and Schedule definition routes, and generated app-* MCP tools are removed. Project creation requires an explicit type; there is no path-specific default for an old endpoint. CLI Project selection uses --project, not --app. Dependency scan and update select one Instance by its Route domain with --project. The same rule applies beyond the repository record: --wireguard-ip has no --wireguard-address alias, and the task driver settings use separate implementer and reviewer environment variables, with no ORBIT_TASKS_AGENT_DRIVER fallback. Older clients that depend on removed names must be updated; Orbit provides no compatibility period. Retaining /apps until clients migrate is rejected, because there is no other user’s migration to protect. Keeping aliases because they seem cheap or harmless is rejected, because they obscure the supported interface and still need maintenance. A conversion window or an inert endpoint is rejected, because neither provides value to this operator. Public compatibility cleanup and stored record naming are separate decisions; coupling them would mix interface removal with database and class renaming. Unrelated names such as the GitHub App and Node roles are not compatibility aliases for Project.

Project and Instance

Project is the repository record. Instance is one running copy of a Project on a Node. Those are the only names for this domain: the model, the table, the foreign key, the class, the API, the CLI, and these docs. Nothing keeps a second name as an alias, a route, a JSON field, a class, a table, or a column. The model for the repository record is Project, and the model for the running copy is Instance. Tables are projects, instances, and instance_*. Project update rows are project_updates. Foreign keys are project_id and instance_id. A path parameter that identifies a Project is {project}. Project actions live in Actions/Projects. A class is Project* or Instance* according to whether it belongs to the repository record or the running copy. The Gateway, the CLI, the PHP SDK, and the web app follow that rule. Laravel’s root namespace stays App\, so those actions are App\Actions\Projects. Environment placeholders are {{instance.domain}} and {{instance.environment}}. A stored value that contains any other {{...}} placeholder is invalid. Synchronization returns env.reference_unavailable. A write that fails placeholder validation returns env.configuration_invalid. The migration rewrites stored placeholders to these two names. Project has no morph alias. The public subject type is project. The Instance morph alias is instance. The migration rewrites stored activity class names to App\Models\Project. Realtime types are project.created, project.updated, and project.deleted. Payload classes are ProjectData, InstanceData, and InstanceDeploymentData. Project error codes use the project. prefix. project.has_instances means removal found Instances. node.has_instances means the Node still has Instances. instance.placement_unavailable means placement failed. Doctor’s repository family is project. Monorepo folders such as apps/cli and apps/gateway, the Laravel root namespace App\, app/ source folders, config/app.php, APP_* variables, Node roles app-dev and app-prod, the GitHub App, the type laravel-app, the Route kind app, and the node storage setting apps are not this domain. Keeping a stored name that differs from Project or Instance was rejected, because every reader would translate. An alias that accepts another name was rejected, because an alias is a second supported path. Renaming the monorepo folders, the Laravel root namespace, the app/ source folders, config/app.php, the APP_* variables, Node roles, the GitHub App, the type laravel-app, the Route kind, or the node storage setting was rejected, because those names are not the Project or Instance record. Reading a stored placeholder or activity class under a different spelling, as if it were the current name, was rejected: that reading is an alias, and synchronization and activity resolution then fail on rows that use the other spelling. The migration renames tables and columns in place. There is no compatibility view and no dual-write. A caller that sends a removed field, or code that imports a removed class, fails. There is no compatibility period. A repository check fails when an App-domain name is present in app code, database code, the SDK, the CLI, web sources, or these pages. A monorepo path under apps/ is not that name. A covers: glob names a file that exists, and the check rejects an App-domain name left in that glob. The same check rejects the Node names the web app used before Concepts settled on Nodes with roles and roleless Nodes.