
## Full application deploy

`POST /v1/infra/servers/:id/deploy`

A full deploy cycle in a single request: install the runtime → stop the previous service → download the archive → extract → install dependencies → write `.env` → run `preStart` commands → create the systemd unit → start → health check. Use it instead of chaining `/upload` + `/exec` — it is faster. If the `runtime` step fails, the deploy does not issue the stop command, clean or replace the application tree, or read or rewrite `.env`. The runtime installer runs as `root` and can change the host's system dependencies, so the previous application is not guaranteed to keep running. After a successful `runtime` step, later failures behave as before: nothing is rolled back, the unit may remain stopped, and the application tree may already have been replaced. By default the response is JSON (recommended for AI agents): the platform answers `200` before the first deploy step and writes the body once all the steps are done — the connection stays open all that time. A sleeping machine is woken up first, and until that wake finishes the client gets neither the status nor the headers, so the HTTP-client timeout has to cover both the wake and the deploy itself. For line-by-line progress, pass `?stream=true` — the server returns an SSE stream (Server-Sent Events) with an event on each step. A sleeping server with a live plan-change operation (`resize`) returns `409 EXEC_BUSY` before waking; wait for that operation to finish.

**For AI agents and MCP clients, always use JSON mode (without `?stream=true`).** The official Vibecode MCP client (the `manage_server_deploy` tool) strips this parameter automatically and always works in JSON mode.

## Application secrets

The application owner or a Bitrix24 account administrator sets secret values on the application card. The [Application secrets](/docs/applications/app-secrets) page explains how to create a name and how a value is applied without a deploy. During deployment, a secret value overrides the `env` field with the same name; a name without a value does not override an explicitly supplied `env` field. The platform controls `PORT`. The response `warnings` field lists ignored names and secrets without values, with a link to the application card and without the values themselves.

On a standalone server, the platform writes a temporary file and then replaces `/var/lib/vibe/app-secrets/<service>.env` as root with mode `0600`, outside the application directory. The directory `/var/lib/vibe/app-secrets` is owned by root with mode `0700`. Neither `extractTo` nor `dataDirs` may overlap this directory, including its ancestors and descendants. The drop-in `/etc/systemd/system/<service>.service.d/50-vibe-secrets.conf` loads it through `EnvironmentFile=-/var/lib/vibe/app-secrets/<service>.env`. Your own `.env` is left unchanged. Deleted names disappear from the file. `install` and `preStart` commands do not receive these secrets.

With `systemd: false`, the file and drop-in are still written. To connect them yourself, use a systemd service with the same `serviceName`, run `systemctl daemon-reload`, and restart that service. Other process managers need a loader for systemd's `EnvironmentFile` format; do not execute this file as a shell script. Reading the file requires root.

In Galaxy, secrets are added only to the container's runtime environment. Migration, restore, and key replacement deliver them again. Previously delivered names that were deleted are passed as empty values so an image cannot bring them back. Restoring into a new application copies encrypted secrets and name history to its card when the owner and the Bitrix24 account match.

Values are not returned by the API or published in deployment logs. **Anyone who can run commands on the machine or run the application's code can read the process environment.** Grant that access only to people allowed to read the secrets.

Store multiline values as base64: encode them into one line before saving the secret and decode them inside your application. The platform does not decode base64 automatically.


## Deploy and application access to the Bitrix24 account

The key used to call `POST /v1/infra/servers/:id/deploy` is not automatically copied into the application environment. A successful deploy and health check do not verify the application's access to Bitrix24 account data.

On a successful deploy with an OAuth application key, the response adds one warning about this boundary: in `warnings` of the JSON response for a dedicated virtual machine and a Galaxy application, or in `warnings` of the SSE `done` event. This is a reminder to configure authentication, not a runtime credential check: it appears regardless of the supplied `env`, `preserveEnv`, or chosen runtime, including `static`. Deploy success and existing warnings are preserved. This warning is not added for a personal key or a failed deploy.

To act as the visitor, supply the application with the key of the same OAuth application that issued the user session. The application server reads the incoming `X-Vibe-Authorization` and forwards its value as `Authorization` in the outgoing `/v1/*` request, together with the application key in `X-Api-Key`. For a headless service, explicitly supply a personal key through `env`, restrict it to the minimum required scopes, and keep server access at `OWNER_ONLY`. See [Application contract](/docs/infra/app-runtime) for both flows.

If your code reads `BITRIX_API_KEY` and `BITRIX_API_BASE_URL`, these are client-chosen variable names. The platform does not create them automatically: supply the required values explicitly and configure the application to read them. The absence of these names in `env` alone does not prove that the application has no credentials.

## Galaxy application — deploy right after creation

If the server is a Galaxy application (the `createdVia` field equals `galaxy` in the response of [`POST /v1/infra/servers`](../servers/create.md)), run the deploy **right after creation**, without waiting for `blackholeStatus: "CONNECTED"` — the container is built by the deploy itself. The placement model, lifecycle, and pricing are on the [Galaxy application](../galaxy.md) page.

Differences in the deploy for a Galaxy application:

- `runtime` is **required**. `start` is required for every runtime except `static`; for `static` it is optional and ignored. Without `runtime` the request returns `400 GALAXY_DEPLOY_RUNTIME_REQUIRED`.
- The source is the inline `source.content` (a base64 archive). The `source.url` and `source.versionId` variants are available where the platform has enabled link deploys for you; otherwise they are rejected with `400 GALAXY_DEPLOY_CONTENT_ONLY`. Prefer a link when you have one: the host downloads the archive itself, so nothing large travels in the request body and the inline size limit does not apply. The link must point at [source storage](/docs/source-storage) — a saved version and its signed link both qualify, while an outside address is rejected with `400 GALAXY_SOURCE_URL_NOT_ALLOWED`. A dedicated virtual machine has no such restriction.
- A `Dockerfile` in the archive is not required — the platform generates it itself from `runtime` and `port`, and for non-static runtimes from `start`. Static always gets the platform nginx command. If you put your own `Dockerfile` in the archive, it is ignored (the platform always generates its own). See "Known specifics" below.
- The container working directory is **`/opt/app`** (the same as `extractTo` on a regular virtual machine): the code is copied there, so both a relative command (`start: "node server.js"`) and an absolute path (`start: "node /opt/app/server.js"`) work. The `/app` path also points to `/opt/app` — it is a symlink kept for backward compatibility. That is, the same `start` works for both VM and galaxy deploys.
- The deploy is addressed by the application's own id (`kind` equals `GALAXY_APP`). The same request with the id of the host machine (`kind` equals `GALAXY`) is refused — `400 GALAXY_HOST_NOT_A_DEPLOY_TARGET`.
- Dedicated virtual machine fields do not apply to a Galaxy application: `preStart`, `systemd`, `serviceName`, `cleanDeploy`, `extractTo`, `hardening`, `preserveEnv`, `dataDirs`, `dataDirsRecursive`. A request carrying them goes through, but the values have no effect. Put the commands that must run before the application starts into `install` — it runs while the container is built. The `healthPath` field does apply and sets the readiness probe path inside the container. The `extractTo` and `dataDirs` paths are still validated: an unacceptable value returns `400 INVALID_EXTRACT_TO` or `400 INVALID_DATA_DIRS`, and the same code comes back for `dataDirsRecursive` without `dataDirs`.

The Galaxy application lifecycle is on the [Galaxy application](../galaxy.md) page. The "deploy in a single request on creation" scenario is on the [Create server](../servers/create.md) page.

## Parameters

| Parameter | In | Type | Required | Default | Description |
|----------|---|-----|:-----:|-----------|----------|
| `id` | path | string (UUID) | yes | — | ID of the BLACKHOLE server, `status: running`, `blackholeStatus: CONNECTED` |
| `stream` | query | string | no | — | `true` — SSE streaming mode. For a JSON response, do not pass the parameter: JSON is returned by default |

## Request fields (body — JSON)

| Field | Type | Required | Default | Description |
|------|-----|:-----:|-----------|----------|
| `source` | object | **yes** | — | The code source — exactly one of the three variants below (`url`, `content`, or `versionId`) |
| `source.url` | string | yes (or `content` / `versionId`) | — | HTTPS URL of the code archive. Up to 500 MB |
| `source.content` | string | yes (or `url` / `versionId`) | — | Base64 string of the archive. Up to 96 MB per request body, which is about 72 MB of the archive itself: base64 runs roughly a third larger than the raw bytes. A body over the cap is refused with `413 INLINE_SOURCE_TOO_LARGE`. Save a larger archive as a version and deploy it through `source.versionId` |
| `source.versionId` | string | yes (or `url` / `content`) | — | Snapshot ID from the application's source storage, for example `v3` — see [Source storage](/docs/source-storage) |
| `start` | string | except `static` | — | Application start command (example: `cd /opt/app && node server.js`). For `runtime: "static"`, the field is optional and ignored because the platform owns the process |
| `port` | number | no | `3000` | Port the application listens on. **Always 3000** for BLACKHOLE — if not passed, it defaults to 3000. (In multipart mode the port is required — see the table below.) The platform itself passes this number to the application in the `PORT` environment variable — see "The platform passes the port in `PORT`" below. |
| `extractTo` | string | no | `/opt/app` | Extraction directory. Validated before the deploy starts — an invalid path is rejected with `400 INVALID_EXTRACT_TO` (see "Errors"). Managed standalone static (`systemd: true`) accepts only a nested application directory under `/opt` or `/srv`; every `/var/lib` path and `/opt/data` are refused. `systemd: false` and other runtimes keep the general path rules |
| `runtime` | string | no | — | Runtime ID: `node24`, `python311`, `php83`, `static`, etc. For the full list and the start command of each, see [`GET /v1/infra/runtimes`](./runtimes.md). The server is created from a stock Ubuntu 24.04 image — without `runtime` it has no `node`, no `python3.11`, no `php8.3`, and `start` fails with `command not found`. A runtime installs its own command: after `python311` a dedicated virtual machine has `python3.11`, and there is no `python` command there. After `python312` it has `python`, while `python3` stays the system interpreter without the app's packages. An alternative is to install the toolchain manually via `preStart` (see below). |
| `install` | string | no | — | Dependency install command (up to 5000 characters) |
| `preStart` | string | no | — | Command run **before** the service starts: DB migrations, seed scripts, build, manual install of system packages (for example, `apt-get update && apt-get install -y nodejs npm` if `runtime` is not passed). Up to 5000 characters. Works for a dedicated virtual machine only (`kind: "STANDALONE"`) |
| `env` | object | no | — | Environment variables `{ "KEY": "value" }` — written to `.env` and picked up by systemd. The `PORT` key is **reserved by the platform**: it is filled with the value of the `port` field, any value you pass is overridden, and the response reports the override in `warnings`. Each variable value must be single-line: a newline or a null byte in a value is rejected with `400 VALIDATION_ERROR`, and the message path points at the offending key — `env.<KEY>`. Pass a multi-line value, for example a PEM-format key, on a single line, replacing line breaks with the two characters `\n`, and restore the breaks on the application side |
| `displayName` | string | no | — | Human-readable app name (2–100 characters) for the Bitrix24 catalog card. **Always set it** — otherwise the catalog shows the technical identifier, and the response returns a warning |
| `description` | string | no | — | Short description of what the app does, for the Bitrix24 catalog card (up to 500 characters) |
| `changelog` | string | no | — | What changed in this version (plain text, up to 2000 characters). When a new version ships, the text is posted to subscribers in the app's Bitrix24 messenger channel feed. Keep it to one or two lines of "what's new". If omitted, the feed post carries only the version number ("Update to vN"). A key that does not change Bitrix24 data does not post the announcement: the version note is still saved, the catalog card and icon are published, and `warnings` carries a notice |
| `baseVersionId` | string | depends | — | The source version this deploy is based on, e.g. `"v12"` — the one you downloaded before editing. If a newer version has appeared in the server's source storage, the deploy is refused with `409 SOURCE_VERSION_STALE`: the body carries the current version number and a download link. **Required as soon as the storage holds any version if either its latest version was saved by another developer or the server has a development team**: a deploy without the field answers `409 BASE_VERSION_REQUIRED` and names the current version. With an empty storage the field is not required for the first deploy; without a team it also remains optional while the latest version is your own, but another developer's latest version makes it required even when no team exists. The value must name the version you actually started from: a number copied out of the error text certifies a merge that never happened. A number ahead of the saved versions (no such version exists) is refused with `400 BASE_VERSION_NOT_FOUND` |
| `systemd` | boolean | no | `true` | Create a systemd unit for autostart |
| `cleanDeploy` | boolean | no | `true` | Clean `extractTo` before extraction. Set to `false` for a merge deploy |
| `preserveEnv` | boolean | no | `false` | Keep the existing `.env` across the clean: the file is read before `extractTo` is wiped and written back at the `env` step. Best-effort return of the saved copy applies only after the snapshot was created and the deploy proceeded to the clean. A `runtime` failure happens earlier: the previous `.env` stays in place, is not read, and is not uploaded back. If this same deploy also passes `env`, it wins and the saved copy is discarded. Works for a dedicated virtual machine only (`kind: "STANDALONE"`) |
| `serviceName` | string | no | `app` | systemd unit name. Only `a-z`, `0-9`, `-`. Creates `{serviceName}.service`. If you change the name between deploys — see "Known specifics" |
| `healthPath` | string | no | `/` | Health check path — for example, `/health`, `/api/status`. Safe characters only. A non-root path is fine as long as the app also answers on `/`: once that path passes, the deploy probes the root as well — a 5xx there stops the deploy with an error, a 4xx yields an `app_root` step with status `warning`. If the app answers ONLY on the subpath, see "Known specifics" |
| `debug` | boolean | no | `false` | Add a diagnostic `debug_info` step — `ls -la`, `sha256sum` of files, `file -i` for debugging build steps |
| `hardening` | string | no | `auto` | How the app is started. `auto` — start it under an unprivileged account and restore the previous mode automatically if the app fails its health check. `off` — start it with administrator privileges right away. See "Known specifics" |
| `dataDirs` | array | no | — | State directories outside `extractTo` that the app itself must write to, for example `["/opt/data"]`. On a hardened deploy the platform creates each one and hands the directory itself to the app account. Up to eight paths; each must be an absolute, already-normalized path inside `/opt`, `/srv` or `/var/lib` with a directory name of its own, otherwise `400 INVALID_DATA_DIRS`. See "Known specifics" |
| `dataDirsRecursive` | boolean | no | `false` | Hand over the CONTENTS of the `dataDirs` entries as well, not just the directories themselves. Needed only for a pre-seeded tree the app has to rewrite. Not accepted for `/opt/data`. See "Known specifics" |

## Request fields (body — multipart/form-data)

An alternative to JSON — upload the archive without base64. Useful from bash/PowerShell scripts.

| Form field | Type | Required | Description |
|------------|-----|:-----:|----------|
| `file` | file | **yes** | Archive (tar.gz / zip). The ceiling is 500 MB as long as the archive streams into source storage, and about 72 MB when streaming is unavailable and the archive is buffered in memory: the refusal then arrives as `413 INLINE_SOURCE_TOO_LARGE`. The three conditions for streaming are in "Known specifics". The archive is also accepted under the field name `archive` |
| `start` | string | except `static` | Start command. For `runtime: "static"`, the field is optional and ignored |
| `port` | string | **yes** | Port (as a string: `"3000"`) |
| `extractTo` | string | no | Defaults to `/opt/app`. The same check as in the JSON variant — managed standalone static accepts only a nested application directory under `/opt` or `/srv` and refuses `/var/lib`, while `systemd: false` and other runtimes keep the general path rules; an invalid path is rejected with `400 INVALID_EXTRACT_TO` |
| `runtime` | string | no | Runtime ID |
| `install` | string | no | Install command |
| `displayName` | string | no | Human-readable app name for the Bitrix24 catalog |
| `description` | string | no | Short description for the Bitrix24 catalog |
| `changelog` | string | no | What's new in this version (up to 2000 characters) — posted to the app's Bitrix24 messenger channel feed. A key that does not change Bitrix24 data does not post the announcement: the version note is still saved, and `warnings` carries a notice |
| `baseVersionId` | string | depends | Same field as in JSON mode: the source version this deploy is based on (`"v12"`). A newer version in storage is refused with `409 SOURCE_VERSION_STALE`. Once storage is non-empty, a missing field is refused with `409 BASE_VERSION_REQUIRED` if the latest version was saved by another developer or the server has a development team; a number ahead of the saved versions is refused with `400 BASE_VERSION_NOT_FOUND` |
| `preStart` | string | no | Command run before start. Works for a dedicated virtual machine only (`kind: "STANDALONE"`) |
| `env` | string | no | JSON string: `{"KEY":"value"}`. This is a plain text form field — sending `env` as a file or Blob part returns `400 VALIDATION_ERROR`. Variable values are single-line, as in JSON mode |
| `systemd` | string | no | `"true"` / `"false"` |
| `cleanDeploy` | string | no | **In multipart mode defaults to `"false"`** (merge). Specify `"true"` for a clean deploy |
| `preserveEnv` | string | no | `"true"` — keep the existing `.env` across the clean and write it back at the `env` step. Best-effort return applies only after the snapshot was created and the deploy proceeded to the clean. A `runtime` failure happens earlier: the previous file is not read and stays in place with no restore upload |
| `serviceName` | string | no | systemd unit name |
| `healthPath` | string | no | Health check path |
| `debug` | string | no | `"true"` — add a diagnostic `debug_info` step |
| `hardening` | string | no | `"auto"` / `"off"` — how the app is started. See "Known specifics" |
| `dataDirs` | string | no | A JSON array as a string: `["/opt/data"]`. The same field and the same validation as in the JSON variant; anything that is not an array is refused with `400 VALIDATION_ERROR`, an invalid path with `400 INVALID_DATA_DIRS` |
| `dataDirsRecursive` | string | no | `"true"` / `"false"`. Only those two literals are read, so a typo cannot switch the mode on |

## Deploy steps

Steps run sequentially. If one fails, the deploy stops.

| Step | Condition | What it does | Timeout |
|-----|---------|------------|---------|
| `runtime` | `runtime` passed | Installs the runtime before `stop_existing`. On a repeat deploy with the same runtime, reinstalls it. If the install fails, the `.env` snapshot, stop, and clean have not started yet. If the install succeeds but the installer has something to report — for example one of the package sources was moved aside to keep apt readable, or the runtime came from a fallback source — the step returns status `warning`, the text of those messages is in `stdout`, and the deploy continues and ends successfully. Such conditions usually settle by themselves on the next deploy | 300s |
| `stop_existing` | always | `systemctl stop {serviceName}` — stops the service with the current `serviceName` and frees the port of any leftover processes from the same deploy. If the port is published by a running Docker Compose container of the app itself — a project whose directory in the Compose label `com.docker.compose.project.working_dir` equals `extractTo` or sits inside it — the step stops that container with `docker stop` and names it in `stdout`. The `start` command is expected to bring the container back up. Any other unrelated process is not terminated: the step returns the `warning` status and the deploy continues. The same applies when the stop could not be confirmed — no result arrived within the allotted time, OR the server answered that it failed to stop. In both cases the step returns `warning`, saying the outcome is unknown, and the deploy moves on — this step never aborts a deploy | 105s |
| `clean` | `cleanDeploy: true` | Deletes everything in `extractTo`. If the server did not run the command (busy with another one, for example), the step returns `error` naming the reason — the deploy stops here rather than later at `download` with a confusing message | 30s |
| `download` | always | Acquires and extracts the archive. On a dedicated VM enrolled in resilient download, the platform downloads `source.url` and relays it through the tunnel; transient network failures get up to 4 attempts with backoff inside a shared 5-minute budget. Before enrollment, the existing VM download is preserved. In the new mode only, permanent URL-policy, HTTP, size, integrity and extraction failures are not retried. `source.content` keeps its previous 2 attempts | Up to 5 min to acquire `source.url` in the new mode; extraction has a separate budget |
| `cleanup_metadata` | after extract | Removes macOS sidecars (`._*`, `.DS_Store`) — silently if the archive is clean. Unsafe symbolic links are removed here as well. If that symlink pass did not cover the whole tree, the step gets status `warning` with the reason in `stdout` and the deploy continues | ~120s |
| `normalize_windows_paths` | after extract | Normalizes Windows-style `\` in ZIP paths from PowerShell `Compress-Archive`. Carries the same symbolic-link pass and the same `warning` status when it did not cover the whole tree | ~120s |
| `debug_info` | `debug: true` | Diagnostics: `ls -la`, `sha256sum` of files, `file -i`, locale. Reads up to 32 KB into `stdout` | — |
| `install` | `install` passed | Runs the dependency install command | 300s |
| `env` | `env` passed, or `preserveEnv: true` saved the previous file | Writes `.env` to `extractTo` with `0600` permissions. When a saved copy is restored, the step returns an explanation in `stdout`. If the deploy aborts after the snapshot was created and the clean began, but before this step, the copy is put back on disk with no separate step in the response. On a `runtime` failure there is no snapshot yet, the previous file is not read, no restore upload runs, and the file stays in place | — |
| `platform_env` | always | Writes the platform file `.vibe-platform.env` to `extractTo` with `0600` permissions — it holds a single `PORT` variable. This step never reads or rewrites your `.env`. If the file cannot be written the step fails and the deploy stops: without it the application would start without knowing its port. The exception is `systemd: false`: the unit is yours there and nothing loads the platform file anyway, so the step returns `warning` and the deploy continues | — |
| `pre_start` | `preStart` passed | Runs the command before the service starts. For DB migrations, seed scripts, build | 300s |
| `service_user` | `systemd: true`, `hardening` is not `off`, and `extractTo` is a subdirectory of `/opt`, `/srv` or `/var/lib` written without a trailing slash and without `.` or `..` segments. On any other path hardening is not applied — the step returns the `warning` status and the app keeps running with administrator privileges | Creates the unprivileged `vibeapp` account and hands it ownership of the `extractTo` directory so the app runs without administrator privileges. If the account cannot be created or ownership cannot be handed over — status `warning`, the deploy continues with the previous privileges | 120s |
| `data_dirs` | The deploy body declared the `dataDirs` field | Creates the declared directories and hands them to the app account; `stdout` lists every path and the hand-over mode. `ok` — every declared directory was handed over. `warning` — one could not be created or handed over, or the deploy turned out not to be hardened and there was nothing to hand over; the deploy continues either way and hardening is not dropped. The step is absent entirely when the field was not sent | 30s per directory (120s in recursive mode) |
| `systemd` | `systemd: true` | Creates `{serviceName}.service` and re-reads the systemd configuration (`systemctl daemon-reload`) | 30s |
| `start` | always | `systemctl enable --now {serviceName}` | 30s |
| `hardening` | the hardened start was rolled back | Emitted **after** `start` and the healthcheck evaluation (the order in `data.steps[]` is `start` → `hardening` → `healthcheck`), because a rollback warning is only possible after a failed start under `vibeapp`. Reports that the app went back to running with administrator privileges. It arrives on a SUCCESSFUL deploy and on a FAILED one alike — always before the terminal `healthcheck` step. It carries the machine-readable `hardeningRollback` field (see the response-field table). The status is always `warning`. `stdout` carries the reason for the rollback and what to do to keep the hardened mode; the tail of the service journal is added on a successful deploy only | — |
| `healthcheck` | always | Health check — up to 10 attempts of `curl localhost:{port}{healthPath}` at a 3-second interval; a response with status 200–399 from the `serviceName` service counts as success. A 200–399 response from an unrelated process holding the port is not counted, and the step fails. If the app runs on Docker Compose from the `extractTo` directory, a 200–399 response is not counted either when a container of that Compose project is restarting — even one that has been restarting since before the deploy — or when a container started during this deploy has restarted since it started, has exited with a non-zero code, is in the `dead` state or is marked `unhealthy` by Docker: the step fails with an error that names the Compose service, its state and the last lines of the container log. **Important:** the status of this step means different things on different server kinds: on a dedicated virtual machine (`kind: "STANDALONE"`) a 4xx/5xx response yields `error` and the deploy fails, while for a Galaxy application (`kind: "GALAXY_APP"`) it yields `warning` and the deploy counts as passed | ~30s |
| `app_root` | `healthPath` differs from `/` | Probes the application root — the address Bitrix24 opens. `ok` — the root answered 200–399. `warning` — it answered 4xx, or the probe never reported back: the deploy continues and the reason is in `stdout`. A 5xx at the root stops the deploy: it ends with an error on the `healthcheck` step and no `app_root` step is returned. With the default `healthPath` (`/`) there is no such step at all — the root has already been checked | ~10s |
| `tunnel_routing` | always | Verifies that the Gateway tunnel routes to the right port. On a regular server that means a command to the agent plus a probe; on a server with a pinned port it means rewriting the agent settings (see `port_pin`). Status `warning` (not `error`) — the check did not pass, the deploy is not aborted | ~10s |
| `port_pin` | the server is pinned, or pinning is enabled for your Bitrix24 account | Writes the port into the agent settings on the machine so it survives a wake and a tunnel repair. `ok` — the port is in the settings; `warning` — the write failed: the application still runs, but after the next wake the agent picks a port on its own again. Details — [Change the port](./port.md) | ~20s |

## Examples

Examples with an external `source.url` include the `X-Skip-Source-Snapshot` header: with source storage enabled, a deploy from a third-party address without it returns `409 SNAPSHOT_REQUIRED`. Details and the alternative via storage are in the "Known specifics" section.

### curl — personal key

```bash
# JSON, Node.js with migrations
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/deploy" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Skip-Source-Snapshot: deploy from external URL" \
  -d '{
    "source": { "url": "https://github.com/user/app/archive/main.tar.gz" },
    "runtime": "node24",
    "install": "cd /opt/app && npm install --production",
    "preStart": "cd /opt/app && npx prisma migrate deploy",
    "start": "cd /opt/app && node server.js",
    "port": 3000,
    "env": { "NODE_ENV": "production", "DATABASE_URL": "postgresql://localhost/mydb" }
  }'

# Multipart — local archive
tar -czf app.tar.gz -C ./my-app .
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/deploy" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -F "file=@app.tar.gz" \
  -F "runtime=node24" \
  -F "install=cd /opt/app && npm install --production" \
  -F "start=cd /opt/app && node server.js" \
  -F "port=3000"
```

### curl — inline archive (minimal set of fields)

```bash
# Canonical inline deploy: source.content + start is enough.
# The archive type is detected automatically from the signature of the first bytes —
# tar.gz, zip, and tar.bz2 are supported, no separate format field is needed.
B64=$(base64 < app.tar.gz | tr -d '\n')
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/deploy" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"source\": { \"content\": \"$B64\" },
    \"start\": \"cd /opt/app && node server.js\",
    \"port\": 3000
  }"
```

Only two fields are required: `source` (one of `content` / `url` / `versionId`) and `start`. The archive can be wrapped as zip (`zip -r app.zip .`) or tar.gz (`tar -czf app.tar.gz -C ./my-app .`) — the platform recognizes both from their content.

### curl — arbitrary stack without a runtime

```bash
# Deploy without runtime — any software is installed via preStart
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/deploy" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Skip-Source-Snapshot: deploy from external URL" \
  -d '{
    "source": { "url": "https://example.com/my-app.tar.gz" },
    "preStart": "apt-get update -qq && apt-get install -y ffmpeg imagemagick",
    "install": "cd /opt/app && npm install --production",
    "start": "cd /opt/app && node server.js",
    "port": 3000
  }'
```

### curl — OAuth application

```bash
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/deploy" \
  -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Authorization: Bearer USER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Skip-Source-Snapshot: deploy from external URL" \
  -d '{
    "source": { "url": "https://github.com/user/fastapi/archive/main.tar.gz" },
    "runtime": "python311",
    "install": "cd /opt/app && pip install -r requirements.txt",
    "start": "cd /opt/app && python3.11 -m uvicorn main:app --host 0.0.0.0 --port 3000",
    "port": 3000
  }'
```

### JavaScript — personal key

```javascript
const res = await fetch(
  `https://vibecode.bitrix24.com/v1/infra/servers/${serverId}/deploy`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': 'YOUR_API_KEY',
      'Content-Type': 'application/json',
      'X-Skip-Source-Snapshot': 'deploy from external URL',
    },
    body: JSON.stringify({
      source: { url: 'https://github.com/user/app/archive/main.tar.gz' },
      runtime: 'node24',
      install: 'cd /opt/app && npm install --production',
      start: 'cd /opt/app && node server.js',
      port: 3000,
    }),
  }
)
const body = await res.json()
if (body.success) {
  console.log(`Application is live: ${body.data.appUrl}`)
} else {
  console.error(`Failed at step ${body.error.step}: ${body.error.message}`)
}
```

### JavaScript — OAuth application

```javascript
await fetch(
  `https://vibecode.bitrix24.com/v1/infra/servers/${serverId}/deploy`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': 'YOUR_APP_KEY',
      'Authorization': 'Bearer USER_SESSION_TOKEN',
      'Content-Type': 'application/json',
      'X-Skip-Source-Snapshot': 'deploy from external URL',
    },
    body: JSON.stringify({
      source: { url: 'https://github.com/user/app/archive/main.tar.gz' },
      runtime: 'node24',
      install: 'cd /opt/app && npm install --production',
      start: 'cd /opt/app && node server.js',
      port: 3000,
    }),
  }
)
```

## Response fields

| Field | Type | Description |
|------|-----|----------|
| `success` | boolean | `true` — all steps completed, the application responds to the health check |
| `data.steps` | array | Array of deploy process steps, in execution order |
| `data.steps[].step` | string | Step name: `runtime`, `stop_existing`, `download`, `install`, `env`, `platform_env`, `pre_start`, `service_user`, `data_dirs`, `systemd`, `start`, `healthcheck`, `app_root`, `hardening`, `tunnel_routing`, `port_pin`, etc. A Galaxy application (`kind: "GALAXY_APP"`) has only two step names — `build` and `healthcheck` |
| `data.steps[].status` | string | `ok` — success, `warning` — the step did not do what it was meant to do but the deploy continues (the explanation is in `stdout` or `stderr`; this is how `stop_existing`, `normalize_windows_paths`, `cleanup_metadata`, `service_user`, `start`, `hardening`, `app_root` and `tunnel_routing` report themselves), `error` — failure (only the last step can have this status). On the `runtime` step `warning` means something different: the install DID succeed, but the runtime installer had something to report — read `stdout` |
| `data.steps[].duration` | number | Step duration in seconds. Not present on every step — only on those that measure their own execution time (`runtime`, `install`, `pre_start`, `service_user`, `normalize_windows_paths`, `cleanup_metadata`, `debug_info`) |
| `data.steps[].stderr` | string | The step's standard error stream (`stderr`) on `status: "error"`, and also on `status: "warning"` for the `start` step — there it carries the original reason the service did not start on the first attempt |
| `data.steps[].stdout` | string | Standard output (`stdout`), populated for the `debug_info` step (up to 32 KB), and also carrying the explanation on steps with the `warning` status — including `runtime`, `service_user` and `hardening` |
| `data.steps[].causeCode` | string | A stable machine-readable cause on a failed `download` only. Values: `DOWNLOAD_RELAY_BUSY`, `DOWNLOAD_URL_DENIED`, `DOWNLOAD_SOURCE_UNREACHABLE`, `DOWNLOAD_HTTP_STATUS`, `DOWNLOAD_TOO_LARGE`, `DOWNLOAD_INTEGRITY_MISMATCH`, `DOWNLOAD_RELAY_INTERRUPTED`, `DOWNLOAD_COMMIT_UNCONFIRMED`, `DOWNLOAD_OUTCOME_UNCONFIRMED`, `DOWNLOAD_EXTRACT_FAILED` |
| `data.steps[].retryable` | boolean | Present only alongside `causeCode`: whether retrying the same request is safe. It is always `false` for `DOWNLOAD_COMMIT_UNCONFIRMED` and `DOWNLOAD_OUTCOME_UNCONFIRMED`: read the [operation list](./operations.md) first and do not repeat the deploy blindly. Do not derive it from `message` text |
| `data.steps[].attempts` | number | Number of attempts to acquire `source.url` that actually started: `0` means refusal before download; `1…4` means the source was requested. Omitted when an old agent returned no terminal outcome and the attempt count cannot be proven |
| `data.steps[].httpCode` | number | The `healthcheck` step only. HTTP status of the LAST probe attempt, and for a Galaxy application the status code the application answered the probe with. **The field is absent** when no attempt got a status (the connection never opened) — the absence itself means "there was no status"; zero or `null` are never sent instead. A Galaxy application loses the field in one more case: a deploy that finishes after the connection to the host has dropped loses the code together with the build log. It is a fact about the attempt, NOT a verdict: a step with `status: "error"` may legitimately carry `200` |
| `data.steps[].healthPath` | string | The `healthcheck` step only. The path that was actually probed (with the `/` default already applied). For a Galaxy application it is the path the platform used to reach the application |
| `data.steps[].portOwner` | string | The `healthcheck` step only. Who holds the port per the last successful attempt: `service` — your service (proven), `foreign` — an unrelated process, `unknown` — **the holder was not established**. `unknown` has four causes. First: the ownership probe never ran because the response was outside the 200–399 range. Second: the response WAS in the 200–399 range and the probe still never ran — earlier in that same attempt the Docker Compose readiness check of this deploy found a container of the project not up, or not settled yet (see the `healthcheck` step in the steps table), so the attempt never got as far as the port. Third: the response WAS in the 200–399 range and the probe was started but never completed — its call broke on a timeout or on the transport, so the attempt learned nothing about the holder; the deploy can still come out green, and then `unknown` means only "we could not check", not "an unrelated process holds the port". Fourth: the probe completed and could not name the holder — that is how the port behaves when it is held not by the unit itself but by a background process your `start` command left behind. So an `httpCode` of 200–399 together with `portOwner: "unknown"` does NOT by itself mean an unrelated process holds the port: for a Compose application it is most often the second cause, and with `status: "error"` the actual reason is named by the step's error text. The value does NOT mean "the port is free" |
| `data.steps[].hardeningRollback` | string | The `hardening` step only. `completed` — the revert finished: the unit and ownership of `extractTo` are back with the administrator, the app runs with full privileges. `incomplete` — the revert started and did not finish: the app MAY still be running under `vibeapp`. The field describes the revert of the UNIT and of `extractTo` ownership, and nothing else |
| `data.serviceName` | string | systemd unit name (echo of the parameter or `"app"`) |
| `data.status` | string | `"running"` after a successful deploy |
| `data.appUrl` | string | The application's HTTPS address: `https://{subdomain}.vibecode.bitrix24.com` |
| `data.sleepAfterMinutes` | number \| null | Saved auto-sleep timeout in minutes at the start of the deploy. Present only in a successful response for a dedicated virtual machine (`kind: "STANDALONE"`), regardless of whether there is a warning or any wake windows. `null` is the "Never" choice. Unless the server is in the `SCHEDULE` run mode, setting `null` is rejected while wake windows are enabled, and while `null` is stored, creating or updating a schedule is rejected too — even for a disabled window. An older record that still holds `null` together with an effective schedule uses the platform's post-window timeout instead |
| `data.source` | object | The result of source auto-save. Only on `success: true`. In SSE mode it arrives as a separate `event: source`. A failed deploy carries no such block, yet the version may already exist in storage — see "Known specifics". For the full contract, see [Source storage](/docs/source-storage) |
| `data.source.autoSaved` | boolean | `true` — the source snapshot is saved or linked to the deploy |
| `data.source.savedVersionId` | string | The `vN` version of the saved snapshot. Present when `autoSaved: true` |
| `data.source.skippedReason` | string \| null | The reason the snapshot was not saved, or `null`. For the list of values, see [Source storage](/docs/source-storage) |
| `data.operationId` | string | Deploy operation ID, matching the `X-Vibe-Operation-Id` response header. Use it to read the outcome afterwards — [Deploy outcome](./operation-status.md). On a dedicated machine the record is opened before the pipeline, but a sleeping machine completes its blocking wake before the id can reach this connection; on a Galaxy application, the record is opened after mutex acquisition and the id arrives only with the terminal response. Absent if the connection dropped before delivery or the platform could not create the record |
| `warnings` | array\<string\> | Present only when there are warnings — for example, `displayName`/`description` were not passed or were not applied (see "Known specifics"), or the `runtime` step ended with status `warning` — the entry then points at the runtime installer's messages in that step's `stdout`. A successful deploy with an OAuth application key also adds a warning about configuring application access to the Bitrix24 account (see above). In SSE mode it arrives in the same `event: done` |

## Response headers

On a dedicated virtual machine (`kind: "STANDALONE"`), the deploy opens the operation record BEFORE the pipeline. On an already available machine the id reaches the client before the work starts. On a sleeping machine the record is already visible in the server operation list, but the blocking wake completes before the HTTP status, headers, or an SSE frame can reach this connection. During a live resize, the refusal arrives before the operation record is created and carries no operationId:

```
X-Vibe-Operation-Id: clz9k2m4x0001qw8h3f7d2n5p
```

The same id is mirrored in the body (`data.operationId` on success, `error.operationId` on failure), and with `?stream=true` it also arrives as the first frame before the first `event: step` — after any blocking wake has completed:

```
event: operation
data: {"operationId":"clz9k2m4x0001qw8h3f7d2n5p"}
```

The frame exists because a browser `EventSource` does not expose response headers at all.

For a Galaxy application (`kind: "GALAXY_APP"`), the record is opened after the application mutex is acquired, on entry to the first step. Its id arrives in the header and terminal JSON body; this branch has no early SSE frame. If the connection dropped earlier, find the run through [`GET /v1/infra/servers/:id/operations`](./operations.md).

**A missing header does NOT mean the deploy never started.** The connection to a sleeping standalone machine may have dropped during the wake, the connection to a Galaxy application may have dropped before the terminal response, or the platform may have failed to create a record and continued without an id. Do not redeploy on that basis — a second run would land on top of an application that may already be up. First read the [recent server operations](./operations.md), state, and logs; for an addressable outcome see [Deploy outcome](./operation-status.md).

**Headers arrive before the pipeline, after any blocking wake; the body arrives at the end.** In JSON mode on a dedicated virtual machine (`kind: "STANDALONE"`) the platform sends `200` and the response headers before the first step and writes the response body once the deploy has finished: the connection is held open with spaces all that time. If the machine was asleep, the deploy creates an operation first and then wakes it; until that wake finishes — up to 6.5 minutes — the client gets neither the status, nor the headers, nor the id itself over this connection. While the request is silent, the operation is already visible as `running`, with no deploy steps, through [`GET /v1/infra/servers/:id/operations`](./operations.md). If the wake returns a refusal and the operation record was created, the response carries the same id in `X-Vibe-Operation-Id` and `error.operationId`; `503 WAKE_TIMEOUT` remains `503`, and after a successful asynchronous write the operation code is `WAKE_TIMEOUT`. The operation can briefly remain `running` just after the response; if the terminal write fails, it later reads as `unknown`. A client with a short timeout closes the connection earlier and sees a `200` whose body is empty or does not parse as JSON, and a drop during the wake leaves it with no response at all. The deploy keeps running on the server either way, and it must not be re-sent. How long the platform holds the connection and which timeout to set — "A long deploy and HTTP-client timeouts" under "Known specifics". Step-by-step progress instead of waiting is what `?stream=true` gives, and the outcome of an interrupted deploy is read by its operation id — [Deploy outcome](./operation-status.md). A sleeping server with a live plan-change operation (`resize`) returns `409 EXEC_BUSY` before waking; wait for that operation to finish. Do not use manual wake to bypass resize.

For a Galaxy application (`kind: "GALAXY_APP"`) the whole response — headers and body alike — arrives once the build has finished, so the same timeout margin is required and there is no "work in progress" signal before the response.

On `success: false` the response additionally carries `error.code`, `error.message`, and `error.step` with the name of the failed step, and `data.steps` contains the timeline of steps up to the failure. If the hardened-start auto-rollback fired, the error envelope also carries `error.hardeningRollback` — the same value as on the `hardening` step. The field exists on the plain response only. With `?stream=true` no envelope carrying it is sent: the outcome is read from the `event: step` frame with `status: "error"` and from the `hardening` step frame preceding it. An `event: error` frame does occur in the stream — when the operation is aborted and when the server is busy with another operation — but it never carries `hardeningRollback`. **An absent field means "no rollback was attempted", NOT "isolation was never applied"** — the latter is answered by the `service_user` step (`ok` — applied, `warning` — not applied, and no such step at all means the deploy never got that far). On a dedicated virtual machine (`kind: "STANDALONE"`) such a response arrives with HTTP status 200 — the connection is held from the start of the deploy, so a failed step is visible in the `success` field, not in the status. For a Galaxy application (`kind: "GALAXY_APP"`) the same error arrives with the status `502`.

**A Galaxy application's response shape differs.** For a Galaxy application (`kind: "GALAXY_APP"`) a successful response carries `data` of the form `{ "id", "status", "appUrl", "buildLog", "steps", "autoSaved", "skippedReason" }`, plus `warnings[]` when `displayName`/`description` are not passed or are not applied, and when deploying with an OAuth application key (see above). The `sleepAfterMinutes` field is not part of this shape — it belongs to a dedicated-virtual-machine deploy response only. The `autoSaved` and `skippedReason` fields sit directly on `data`, not on `data.source`, and there is no `data.source.savedVersionId` field in the response — read the source auto-save outcome from `data.autoSaved`. Build details stay in the `buildLog` field.

A Galaxy application's `data.steps[]` array is shorter than a dedicated machine's and holds at most two steps:

```json
"steps": [
  { "step": "build", "status": "ok" },
  { "step": "healthcheck", "status": "ok", "httpCode": 200, "healthPath": "/health" }
]
```

The `build` step with the `ok` status is in every successful response. The `healthcheck` step is added only when the platform made an HTTP request to the application: `status: "ok"` — the application answered with a `2xx` or `3xx` code, `status: "warning"` — it answered with a `4xx` or `5xx` code, and the deploy still counts as successful because the application is reachable.

## Response example

A successful deploy (JSON):

```json
{
  "success": true,
  "data": {
    "steps": [
      { "step": "runtime", "status": "ok", "duration": 45 },
      { "step": "stop_existing", "status": "ok" },
      { "step": "download", "status": "ok" },
      { "step": "install", "status": "ok", "duration": 12 },
      { "step": "env", "status": "ok" },
      { "step": "platform_env", "status": "ok" },
      { "step": "systemd", "status": "ok" },
      { "step": "start", "status": "ok" },
      { "step": "healthcheck", "status": "ok" }
    ],
    "serviceName": "app",
    "status": "running",
    "appUrl": "https://app-abc12345.vibecode.bitrix24.com",
    "sleepAfterMinutes": 60,
    "source": { "autoSaved": false, "skippedReason": "deploy from external URL" }
  }
}
```

A `source.url` acquisition failure retains the top-level `DEPLOY_FAILED` code, while stable details are duplicated in `error` and in the terminal step:

```json
{
  "success": false,
  "error": {
    "code": "DEPLOY_FAILED",
    "message": "The source could not be reached within the retry budget.",
    "step": "download",
    "causeCode": "DOWNLOAD_SOURCE_UNREACHABLE",
    "retryable": true,
    "attempts": 4
  },
  "data": {
    "steps": [
      {
        "step": "download",
        "status": "error",
        "error": "The source could not be reached within the retry budget.",
        "causeCode": "DOWNLOAD_SOURCE_UNREACHABLE",
        "retryable": true,
        "attempts": 4
      }
    ]
  }
}
```

The `data.source` block is the result of source auto-save. In the examples above the deploy comes from an external URL with the `X-Skip-Source-Snapshot` header, so `autoSaved: false` and `skippedReason` echoes the passed reason. On a deploy from storage — `source.content` or `source.versionId` — the block looks like `{ "autoSaved": true, "savedVersionId": "vN", "skippedReason": null }`. For the full contract and the `skippedReason` values, see [Source storage](/docs/source-storage).

The `warnings` field in a successful response appears only when there is something worth flagging — for example, `displayName`/`description` were not passed or were not applied (see "Known specifics"), or the `runtime` step ended with status `warning` and its messages are waiting in that step's `stdout`. In JSON mode it is `warnings` at the top level of the response, alongside `data`; in SSE mode it is the same field in the final `event: done`.

A successful deploy of a regular dedicated virtual machine that is not covered by agent/bot idle-sleep protection appends one entry starting with `AUTO-SLEEP POLICY:` to `warnings[]` when the snapshot at the start of the deploy has a numeric `sleepAfterMinutes` and no enabled recurring wake window. Disabled windows do not suppress it; at least one enabled window or `sleepAfterMinutes: null` does. This is only the display condition for the deploy warning: its absence does not mean the server cannot sleep. An enabled window can suppress the warning while the server still sleeps after its saved numeric timeout. If an older record already contains `null` plus an effective schedule, the platform's post-window timeout applies. Unless the server is in the `SCHEDULE` run mode, current writes reject setting `null` while windows are enabled, and reject any schedule creation or update while `null` is stored — even when the window is disabled. The check and the returned value refer to the same starting snapshot: a policy change during the deploy appears only in the next response. The warning is advisory and does not make the deploy fail.

During lengthy runtime preparation, the SSE stream repeats the `event: step` frame with `status: "running"` and an `elapsedSeconds` field. It is the number of whole seconds since the step started; the field is absent from the first frame and from every terminal `ok`/`error` frame. The plain JSON response still completes only when the operation ends and keeps the connection alive with whitespace, so use `?stream=true` for meaningful intermediate progress.

SSE stream with `?stream=true`:

```
event: step
data: {"step":"runtime","status":"running"}

event: step
data: {"step":"runtime","status":"running","elapsedSeconds":15}

event: step
data: {"step":"runtime","status":"ok","duration":45}

event: step
data: {"step":"stop_existing","status":"ok"}

event: step
data: {"step":"download","status":"ok"}

event: step
data: {"step":"install","status":"ok","duration":12}

event: step
data: {"step":"healthcheck","status":"ok"}

event: done
data: {"serviceName":"app","status":"running","appUrl":"https://app-abc12345.vibecode.bitrix24.com","sleepAfterMinutes":60,"warnings":["displayName and description were not set on this deploy — the Bitrix24 catalog card uses displayName as the title and description as the subtitle, and falls back to the server's technical identifier (slug) without them. The deploy is NOT complete until they are set: re-send the deploy with displayName and description added so the app appears in the catalog with an adequate name."]}

event: source
data: {"autoSaved":false,"skippedReason":"deploy from external URL"}
```

## Error response example

The deploy failed at the install step (HTTP 200, `success: false`):

```json
{
  "success": false,
  "error": {
    "code": "DEPLOY_FAILED",
    "message": "npm ERR! Missing dependencies",
    "step": "install"
  },
  "data": {
    "steps": [
      { "step": "stop_existing", "status": "ok" },
      { "step": "download", "status": "ok" },
      { "step": "install", "status": "error", "stderr": "npm ERR! Missing dependencies" }
    ]
  }
}
```

The app came up, but the health check answers with an error, and the platform has already rolled it
back to administrator privileges (HTTP 200, `success: false`):

```json
{
  "success": false,
  "error": {
    "code": "DEPLOY_FAILED",
    "message": "App returned HTTP 503 at /health (process is up but responding with error). …",
    "step": "healthcheck",
    "hardeningRollback": "completed"
  },
  "data": {
    "steps": [
      { "step": "service_user", "status": "ok" },
      { "step": "systemd", "status": "ok" },
      { "step": "start", "status": "ok" },
      { "step": "hardening", "status": "warning", "hardeningRollback": "completed", "stdout": "…" },
      { "step": "healthcheck", "status": "error", "httpCode": 503, "healthPath": "/health", "portOwner": "unknown", "error": "…" }
    ]
  }
}
```

What a program reads here without parsing any prose: `httpCode: 503` — the app answers over HTTP, so
the code is deployed and the process is up; `hardeningRollback: "completed"` — isolation was removed
and the app runs with administrator privileges. The deploy is still **not** counted as successful:
`success` and the step status decide that, not `httpCode`. `portOwner: "unknown"` is normal here —
the port-ownership probe does not run on 5xx responses.

## Errors

| HTTP | Code | Description |
|------|-----|----------|
| 400 | `VALIDATION_ERROR` | The request schema is violated: `source` or `start` missing, the archive file not passed in multipart mode, `port` out of the 1–65535 range or not passed in multipart mode, invalid archive format |
| 400 | `NOT_BLACKHOLE` | The server is in OPEN mode — the Deploy API is unavailable |
| 401 | `MISSING_API_KEY` | The `X-Api-Key` header is not passed |
| 401 | `INVALID_API_KEY` | The key is not recognized — no such key exists on the platform |
| 402 | `ACCOUNT_FROZEN` | The Vibecode balance is frozen |
| 402 | `BILLING_EXHAUSTED` | The balance is exhausted — top it up |
| 402 | `INT_TARIFF_REQUIRED` | The portal is on a free Bitrix24 plan, so a new version of an app that already exists is refused just like creating a new app: the check comes first, before the server is woken and before the archive is accepted, and the refusal leaves nothing behind. The app that is already running keeps responding. The upgrade path is in `error.alternatives` and `error.details.upgradeUrl`. [Plan and access](/docs/infra#plan-and-access) explains which access path applies to the portal |
| 402 | `INT_VIBE_PLUS_REQUIRED` | Account access is narrowed to a paid Vibe+ plan, and an ordinary commercial Bitrix24 plan is no longer enough, so a new version of an app that already exists is refused as well. The link in `error.details.upgradeUrl` opens the plan offer in the Bitrix24 account. An account on a trial plan keeps its trial access and does not receive this code |
| 403 | `PORTAL_TARIFF_UNREADABLE` | The account plan could not be read, so the response does not name the plan that is missing: `error.details.requiredTariffs` is empty and the button points to support. Buying a plan does not clear this refusal |
| 403 | `INFRA_FORBIDDEN_FOR_COWORK_KEY` | The call was made with a Cowork/Code key — such a key works with data only and cannot perform write operations. To issue a key that can, see [Project key for deploy](/docs/cowork/deploy-key) |
| 403 | `WRONG_KEY` | The server exists but is not yours. Operations on the application's content — deploy, exec, upload and log reads — are open on any one of three grounds: the server's managing key, a key whose application is bound to that server (`Application.serverId`), or membership in that server's development team (both roles, Developer and Administrator). Access tokens and the icon upload require the managing key. Managing the machine itself is also open to the Administrator role on the team. The response carries a `hint` with a two-step recovery. See [Server access recovery](/docs/infra/server-access-recovery) |
| 404 | `NOT_FOUND` | There is no server with this `id`, or it was deleted. A server that exists but is not yours answers `403 WRONG_KEY` — the code tells "no such server" apart from "no rights to it" |
| 403 | `AGENT_SLOT_DEPLOY_FORBIDDEN` | The server belongs to an AI agent — you cannot deploy an application into it, because that would overwrite the agent's code. Click "Retry" on the agent card to redeploy it, or create a separate server for the application |
| 403 | `SERVER_WAKE_BLOCKED` | The server is asleep and waking is blocked — for example, platform access has ended or an administrative block is in place. The deploy does not bring such a server up, and a retry will not help until the block is lifted. For details on the block, see [Wake a server](/docs/infra/lifecycle/wake) |
| 403 | `EXTERNAL_COLLABORATOR_KEY_SERVER_MISMATCH` | The call was made with an external-collaborator key, and the address names a server other than the one the key was issued for. Such a key is valid for exactly one server, and the response does not reveal whether the requested server exists. Working with another server requires the key issued for that external membership |
| 403 | `EXTERNAL_COLLABORATOR_NOT_A_MEMBER` | An external-collaborator key addresses its own server, but access is not granted right now: the membership was removed, it expired, or external development collaborators are switched off for the Bitrix24 account that owns the server. The code does not tell these reasons apart and reveals nothing about the server. Membership is re-read on every request, so revoked access stops working immediately |
| 409 | `WAKE_IN_PROGRESS` | Another request is already waking this sleeping server. Wait for it to finish and repeat the deploy |
| 409 | `CONFLICT` | The sleeping server changed state between the access check and the wake. Read the server's current state and the operation outcome, then decide whether another deploy is needed |
| 422 | `VM_MISSING` | The server record has no virtual machine on the provider side, so there is nothing to wake. Replace the server: delete it and create a new one. `error.hint` names the endpoint of each step only if that step is available to this request, and for an unavailable step it gives the reason for the refusal and what to change. The `error.userMessage` field arrives only when both steps are available |
| 422 | `VM_POINTER_LOST` | The record has no `externalId`, but the cloud reconciliation does see a machine under this server's cloud name: the link is lost while the machine itself is most likely running and billed. Do NOT delete the server and create a new one — that would leave the machine running with no record at all. The server is not moved to `error`; contact support to have the link restored |
| 502 | `WAKE_FAILED` | Server readiness could not be confirmed. This response does not mean that the server was stopped. Check the [current server state](/docs/infra/servers/get) before taking another action and the [operation outcome](./operations.md); do not retry the deploy without checking it |
| 502 | `PROVIDER_ERROR` | The cloud provider returned an error while starting the sleeping server's virtual machine. The tunnel is not involved here, so [`/repair`](/docs/infra/lifecycle/repair) will not help |
| 503 | `WAKE_NO_CAPACITY` | Startup has already been refused for lack of capacity. Read the [current server state](/docs/infra/servers/get) before trying again |
| 503 | `WAKE_TIMEOUT` | The sleeping server did not come up within the allotted 6.5 minutes — either the machine never started or the tunnel never connected. The server returns to `sleeping`, so a repeat request is safe. If the operation record was created, the response carries its id in `X-Vibe-Operation-Id` and `error.operationId`; after a successful asynchronous write the operation outcome is `failed` with code `WAKE_TIMEOUT`. See "Known specifics" |
| 409 | `EXEC_BUSY` | A sleeping server with a live plan-change operation (`resize`) returns `409 EXEC_BUSY` before waking; wait for that operation to finish. Another operation is already running on the server. The response carries a `Retry-After` header and `retryable: true` / `retryAfter` (seconds) fields — retry at that interval. If that does not help, call [`POST /v1/infra/servers/:id/unstick`](/docs/infra/servers/unstick), which answers `409 OPERATION_IN_PROGRESS` while a lock is observed, and clears one itself only when it is an exec mutex past its own TTL. The unconditional [`DELETE /lock`](./lock.md) comes only after the server is independently confirmed idle |
| 409 | `SERVER_NOT_READY` | The server is not ready for the operation: it is not running, the tunnel is not connected, or the server is listed as connected but a successful non-empty Gateway snapshot confirms that it has no live tunnel. An empty or unavailable snapshot does not confirm absence. The response carries a `hint` field with the reason and the next step. The platform attempts to restore a confirmed-missing tunnel itself. For a stopped machine, call [`/start`](/docs/infra/lifecycle/start) first; for a stale or `DISCONNECTED` tunnel, call [`/repair`](/docs/infra/lifecycle/repair), then repeat the request. The deploy wakes a sleeping server itself. A sleeping server with a live plan-change operation (`resize`) returns `409 EXEC_BUSY` before waking; wait for that operation to finish. |
| 409 | `AGENT_UPDATE_REQUIRED` | A standalone deploy whose `extractTo` is `/srv` or below it requires agent 1.3.22 or newer, regardless of runtime and `systemd`. Galaxy ignores `extractTo` and does not use this gate. The standalone request is refused before the service is stopped or the directory is cleaned; update the agent and retry |
| 409 | `SNAPSHOT_REQUIRED` | A deploy from an external `source.url` with source storage enabled. Pass `X-Skip-Source-Snapshot: <reason>` or upload the archive to storage and deploy via `{source: {versionId}}` — see "Known specifics" |
| 409 | `BASE_VERSION_REQUIRED` | This deploy omitted `baseVersionId` while the source storage was non-empty and either its latest version belonged to another developer or the server had a development team. If the working copy came from `latestVersionId`, retry with that value as its base; otherwise download that version and reconcile the changes first |
| 409 | `SOURCE_VERSION_STALE` | The declared `baseVersionId` is older than `latestVersionId`. Download the latest version, re-apply the changes on top of it, and only then retry with that version as the base. Copying the returned number into a blind retry can overwrite somebody else's work |
| 400 | `INVALID_EXTRACT_TO` | The `extractTo` path is not acceptable: it must be absolute, built from letters, digits, dot, underscore, dash and slash, without a trailing slash, inside one of the roots `/opt`, `/srv`, `/tmp`, `/root`, `/var/log`, `/var/lib`, `/etc/systemd`, `/etc/nginx`, and it may not point at key or schedule directories (`/root/.ssh`, `/etc/cron*` and the like). Managed standalone static with `systemd: true` has a narrower rule: a nested application directory under `/opt` or `/srv`; every `/var/lib` path and `/opt/data` are refused. Other runtimes and static with `systemd: false` use the general rule. The response text names the rule that was violated. The check runs before the deploy touches the server; on a Galaxy application the field is ignored but still validated |
| 400 | `INVALID_DATA_DIRS` | A `dataDirs` entry is not acceptable: every path must be absolute, already normalized, inside `/opt`, `/srv` or `/var/lib`, and carry a directory name of its own — the bare roots (`/opt`, `/var/lib`) are refused. The same code is returned when `dataDirsRecursive` is sent without `dataDirs`, or requested for `/opt/data`. The response text names the offending path and what to send instead. The check runs before the deploy locks the server |
| 400 | `GALAXY_DEPLOY_RUNTIME_REQUIRED` | A Galaxy application deploy without `runtime`. Specify a runtime, for example `node24` |
| 400 | `GALAXY_DEPLOY_CONTENT_ONLY` | A Galaxy application deploy with `source.url` or `source.versionId` where link deploys are not enabled for you. Send the archive as the inline `source.content` |
| 400 | `GALAXY_SOURCE_URL_NOT_ALLOWED` | The link in `source.url` failed the check that runs before it is sent to the galaxy host. Reasons: the address does not point at the platform [source storage](/docs/source-storage), the value is not an absolute URL, the scheme is neither `http` nor `https`, the address does not point at the public network — a local, internal or service host. The response text names the rule that fired, and for an outside address it names the replacement — save the archive as a version and deploy it by `source.versionId`. The same check runs on [server creation](/docs/infra/servers/create) with a `source` field. Do not confuse it with `GALAXY_DEPLOY_CONTENT_ONLY`, where a link is refused as a deploy method altogether, while here one particular address is not accepted |
| 400 | `SOURCE_VERSION_REQUIRES_APP` | A deploy by `source.versionId` onto a server owned by another key — a management key, or access through an application card. The version is looked up in the application context, and the key owning the server is not linked to an application. Deploy the version with the key that owns the server |
| 404 | `SOURCE_VERSION_NOT_FOUND` | Galaxy application only: the `source.versionId` version is not in this application's source storage |
| 404 | `SOURCE_VERSION_NOT_FOUND` | Dedicated machine only: the version is neither in the server's own source storage nor in the owning application's source storage. When the server belongs to the calling key, the response text suggests saving the archive first via `POST /v1/infra/servers/:id/sources` |
| 410 | `SOURCE_VERSION_BYTES_PURGED` | The version record is alive, but its bytes have been purged from storage. Save the archive again — the previous bytes cannot be restored. See [Source storage](/docs/source-storage) |
| 400 | `GALAXY_HOST_NOT_A_DEPLOY_TARGET` | The deploy was addressed to a galaxy host machine ID (`kind: "GALAXY"`). The machine carries application containers and is not a deploy target — address the application itself (`kind: "GALAXY_APP"`), see [Galaxy application](../galaxy.md) |
| 400 | `GALAXY_DEPLOY_INVALID_INSTALL` | A Galaxy application deploy with a multiline `install` command. For a Galaxy application `install` must be a single-line command — a newline breaks the build |
| 409 | `GALAXY_BUILD_BUSY` | Another application is building on this galaxy host right now. Retry in a few seconds |
| 409 | `GALAXY_APP_BUSY` | Another operation is already running on this Galaxy application. Retry in a few seconds |
| 413 | `GALAXY_UPLOAD_TOO_LARGE` | The source archive exceeded the gateway upload limit (before the build ran). This is deterministic — re-sending the same archive will not help. Shrink the archive (exclude `node_modules`, `.git` and build artifacts — dependencies are installed on the host). If link deploys are enabled for you, the same archive can be sent as `source.url` — the host downloads it and this limit does not apply; otherwise shrinking the archive is the only recovery |
| 413 | `INLINE_SOURCE_TOO_LARGE` | The body carrying an inline archive is over 96 MB, which is about 72 MB of the archive itself. The decision is based on the `Content-Length` header before the body is read, so the refusal is deterministic — re-sending the same body fails identically. It saves no traffic: the platform accepts the whole body first, and the refusal arrives once the upload has finished. `error.hint` carries four strings — `reason`, `recovery`, `recoveryAction` and `note` — with a ready-made switch to the versioned path. The same code arrives in multipart mode when the archive could not be streamed into storage and turned out to be larger than 72 MB — the conditions are in "Known specifics" |
| 413 | `PAYLOAD_TOO_LARGE` | The request body exceeded the platform's edge limit — 500 MB — and was rejected before the handler. On this route it is multipart mode that runs into the edge limit: an inline base64 archive has a lower ceiling and is refused with `INLINE_SOURCE_TOO_LARGE` instead. The same edge limit applies on the other `/v1/` routes; AI routes return their error in an OpenAI-compatible envelope. Distinct from `GALAXY_UPLOAD_TOO_LARGE`, which comes from the gateway during a galaxy deploy |
| 502 | `GALAXY_BASE_IMAGE_UNAVAILABLE` | The build could not fetch its base image — the image registry was unreachable from the galaxy host. This is transient: the response carries `retryable: true` and an `error.hint` of four strings — `reason`, `recovery`, `recoveryAction`, `note`. The application slot is not marked broken — including when the server is created with a `source` field (the deploy response has already gone; the cause stays in `provisionError`). Re-send the same deploy at a few-minute interval: the outage is usually short, but it can also be long. There is no need to delete and recreate the application — a fresh slot lands on the same host |
| 502 | `GALAXY_LOW_DISK` | The galaxy host ran out of free disk space during the build. This is retryable: the response carries `retryable: true` and an `error.hint` of four strings — `reason`, `recovery`, `recoveryAction`, `note`. The application slot, its container and its `/data` volume are intact, the application is not marked broken, and the version already running keeps serving requests. Retrying makes sense once space has been freed on the host: it does not free itself, so an immediate retry hits the same refusal. Creating a server with `source` in the same request does not mark the application broken either — the reason arrives on the server record in `provisionError` with the `RESOURCE` category |
| 409 | `GALAXY_UPLOAD_BUSY` | Another upload is using the source path. Retry the same deploy after at least 10 seconds; the body carries `retryable: true`, `retryAfter: 10` and `hint`, and the header is `Retry-After: 10`. The app status is preserved |
| 502 | `GALAXY_APP_BUILD_FAILED` | The Galaxy application container build failed. The tail of the build log is in the `buildLog` field. When the cause is recognized, the response additionally carries `error.category` and `error.buildHint` — see "Known specifics" |
| 502 | `GALAXY_APP_START_FAILED` | The Galaxy application container built, but crashed or went into a restart loop due to out-of-memory right after start. The log tail is in the `buildLog` field. The `error.category` and `error.buildHint` fields arrive by the same rules as for `GALAXY_APP_BUILD_FAILED` |
| 502 | `GALAXY_DEPLOY_INTERRUPTED` | The deploy could not be confirmed. When the durable operation status is `unknown`, the response carries `retryable: false`: read the operation list, server state, and logs before any retry. Only a proven failed operation whose hint permits the same request carries `retryable: true`. `error.subcause` names the drop shape: `http_window_exhausted`, `exec_channel_busy`, `no_this_deploy_container`, `tail_unreached`, or `source_fetch_interrupted`. `error.repeated` turns `true` after several interruptions of the same app in a short window: stop looping the deploy, inspect whether the app starts (start command, port, dependencies, environment variables, memory limit), and contact support with the server ID when the app is known-good. The slot, container, and `/data` volume are preserved in every one of these outcomes; do not delete or recreate the app |
| 502 | `GALAXY_HOST_UNREACHABLE` | The galaxy host is unreachable — the host tunnel is not in `CONNECTED` status. If the response carries `retryable: false`, the deploy was dispatched but its durable outcome is `unknown`: read the operation list, server state, and logs first; do not repeat the deploy blindly. Without `retryable: false`, the refusal happened before build dispatch — follow `error.hint`, which permits a retry after the tunnel recovers. The application and its data are preserved |
| 502 | `GALAXY_APP_DEPLOY_FAILED` | Failed to deploy the Galaxy application — an unexpected deploy failure. Retry the request |
| 502 | `SOURCE_DEPOT_UNAVAILABLE` | Multipart mode only: source storage did not accept the archive, so the deploy did not start. The request body is fine — retry the same request unchanged. This failure used to arrive as `400 VALIDATION_ERROR`, so a client that branches on the status code now lands in a different branch |
| 429 | `RATE_LIMITED` | The limit of 10 operations per minute per API-key-and-server pair was exceeded. The exact value arrives in the `x-ratelimit-limit` header (the cap is divided across replicas) |
| 429 | `DEPLOY_BACKEND_BUSY` | Too many concurrent requests that carry the archive in the request body: the counter is shared by `/deploy`, [`/upload`](/docs/infra/deploy/upload) and [server creation](/docs/infra/servers/create) with a `source.content` field. The response has a `Retry-After: 30` header — retry after that time, or pass the archive by link in `source.url`: a link does not consume an inline-memory slot, but the platform relay has a separate bounded pool |
| 200 | `DEPLOY_FAILED` | One deploy step failed. `error.step` names it. For `download`, the fields `error.causeCode`, `error.retryable` and — when it is known — `error.attempts` duplicate the stable values of the terminal step; branch on them, not on text. `DOWNLOAD_RELAY_BUSY` is returned after a successful `runtime`, when one was passed, but before `stop_existing` and `clean`: the orchestrator has not stopped the unit or cleaned the application tree, but the runtime installer may already have changed host dependencies. Those changes are not rolled back. There is no point retrying `DOWNLOAD_URL_DENIED`, `DOWNLOAD_TOO_LARGE`, `DOWNLOAD_INTEGRITY_MISMATCH`, or `DOWNLOAD_EXTRACT_FAILED` without fixing the cause. `DOWNLOAD_COMMIT_UNCONFIRMED` and `DOWNLOAD_OUTCOME_UNCONFIRMED` mean the durable operation outcome is `unknown`, always carry `retryable: false`, and require reading the operation list first. If hardened-start auto-rollback fired, the envelope also carries `error.hardeningRollback` |
| 200 | `DEPLOY_TIMEOUT` | The gateway stopped waiting for a deploy step, but that does not prove the server-side command stopped. The durable operation outcome is `unknown`: follow `error.hint` and read the [operation list](./operations.md) first; do not repeat the deploy blindly |
| 200 | `DEPLOY_FAILED` with reason `GATEWAY_UNREACHABLE` | The platform lost contact with the gateway and does not know the operation outcome. `error.hint` forbids a blind retry and points first to the [operation list](./operations.md); `unstick` is allowed after a confirmed `EXEC_BUSY`: it is guarded and answers `409` against a live operation as long as no `force` is passed. The INDEPENDENT idleness check — the server state and the logs — gates BOTH steps: `/unstick`, whose tunnel bounce kills whatever the agent is running, and all the more the unconditional `DELETE /lock` after it. The operation list does not prove that: it shows only the runs of your own key. A missing `error.hint.holder` is no proof either: that read is fail-open, so "no holder" is also what arrives when it could not be read |
| 200 | `DEPLOY_CONNECTION_TERMINATED` | The connection to the server was interrupted mid-deploy — the agent or the tunnel dropped. The durable operation outcome is `unknown`: read the [operation list](./operations.md), server state, and logs through `error.hint`; do not repeat the deploy blindly |
| 200 | `DEPLOY_TUNNEL_STALE` | The Gateway has no live tunnel for the server even though the server is listed as connected. The drop does not prove the outcome of an already-dispatched deploy: read the [operation list](./operations.md) and perform the read-only checks from `error.hint` first; do not call `/repair` or repeat the deploy blindly |
| 502 | `RUNTIME_FAILED` | The runtime install on the server failed. Try without `runtime` or via a separate [`/exec`](./exec.md) |
| 503 | `RUNTIME_TIMEOUT` | The runtime install did not complete within 10 minutes. It may still finish — check the server status before retrying. The response body carries a `retryAfter` field with the value 30, and the response has a `Retry-After: 30` header — that is the number of seconds to wait before retrying |

> **The HTTP status does not reflect the deploy's outcome.** On a dedicated virtual machine (`kind: "STANDALONE"`) the connection is held open for the whole deploy, and the `200` status is sent before it begins — so both a successful and a failed deploy arrive with the code `200`. The failure marker is the `success: false` field and the `error` object in the response body. **Check `success` in the body, not the HTTP status:** a client that branches on the status will treat a failed deploy as successful. For a Galaxy application (`kind: "GALAXY_APP"`) the same error arrives with the status `502`.
>
> In streaming mode (`?stream=true`) a step failure arrives as an SSE `step` event with `status: "error"` — it carries the name of the failed step. On an exception or a transport drop, a separate `error` event carries `code` and `message`. On a stuck exec mutex, the terminating `error` event carries `code: "DEPLOY_FAILED"`, `step`, and `hint`, but no `message`. There is no `success` field in the stream — the failure marker for a step is `status: "error"` in its `step` event.

The full list of common API errors — [Errors](/docs/errors).

## The platform passes the port in `PORT`

An application does not have to know its port number up front: the platform passes it in the `PORT`
environment variable — the same convention Heroku, Render, Railway and Cloud Run use. The value is
always the request's `port` field (`3000` by default) — the same number the platform forwards public
traffic to and runs the health check against.

So ordinary code just works:

```js
require('http').createServer(handler).listen(process.env.PORT, '0.0.0.0')
```

How it is delivered:

- **Dedicated virtual machine** (`kind: "STANDALONE"`) — the `platform_env` step writes a separate
  file `.vibe-platform.env` next to your `.env`, holding a single `PORT` line, and the systemd unit
  loads both files: yours first, the platform's second. Systemd applies environment files in
  declaration order, so for a variable set in both files the later one wins — the platform file. Your `.env`
  is neither read nor rewritten in the process.
- **Galaxy application** (`kind: "GALAXY_APP"`) — `PORT` is passed to the container as an
  environment variable at start, alongside your own variables.

What to keep in mind:

- **`PORT` is reserved by the platform.** If you pass your own `env.PORT` that differs from the
  `port` field, the platform overrides it and says so with a line in the response `warnings[]` of
  the form: `Deploy field "port" is 3000; your env.PORT was "9999" and was overridden`. This is not
  an error — the deploy succeeds. Passing a matching value is fine — there will be no warning.
- **If you kept something other than your listen port in `env.PORT`** — a database port, an upstream
  service port — rename that variable. Your value used to reach the application; now `PORT` belongs
  to the platform and gets overridden, and the `warnings[]` entry is about exactly that case.
- **An application that reads the `.env` file directly** (instead of the process environment) will
  not find `PORT` there: the platform value lives in a separate file. Read `process.env.PORT` —
  common loaders (`dotenv` and friends) do not overwrite already-set environment variables by
  default, so such code works unchanged.
- **Your own systemd unit (`systemd: false`).** You own the unit in that mode, so the platform file
  is not loaded automatically — add the line `EnvironmentFile=-<extractTo>/.vibe-platform.env`
  **after** the line with your `.env`. Until you do, your `env.PORT` keeps winning, and the response
  warning says exactly that instead of "was overridden". The platform still writes the file, but if
  that write fails the deploy is not aborted in this mode — the `platform_env` step returns
  `warning`.
- **Already running applications** receive `PORT` on their next deploy — the environment of a
  running process is not changed retroactively.

## Known specifics

- **The multipart archive ceiling depends on whether the archive streamed into storage.** The former 500 MB applies only when three conditions hold at once: you reach the server as its direct owner, not through another application's card and not with a management key; the server is either not a Galaxy application, or link deploys for Galaxy applications are fully enabled for you; and source storage is enabled both platform-wide and for your Bitrix24 account. If even one of them fails, streaming does not kick in, the archive is buffered in memory whole, and its ceiling becomes the same as for an inline base64 body: about 72 MB of archive, refused with `413 INLINE_SOURCE_TOO_LARGE` and the same `error.hint`. The path that does not depend on these conditions is to save the archive as a version through `POST /v1/infra/servers/:id/sources` and deploy it with `{"source": {"versionId": "vN"}}`: there the bytes stream and run into the source-version cap instead, 500 MB of archive. For how storage works, see [Source storage](/docs/source-storage).
- **In multipart mode the source version is created before the deploy starts.** The archive is streamed into storage while the request is still being read, and the version is marked `failed` right away — a successful deploy switches it to `success`. So a failed deploy leaves a version in history with the status `failed`: that is not junk but an honest note saying "the bytes were accepted, the deploy did not get through". For what the history contains, see [Source storage](/docs/source-storage). If storage itself did not accept the archive, the deploy does not start at all and the request returns `502 SOURCE_DEPOT_UNAVAILABLE` — the body is fine, a plain retry helps.
- **The deploy FILLS IN the Bitrix24 catalog card's name and description — it does not rename them.** `displayName` is written only while it still equals the server's technical identifier, `description` only while it is empty. Pass both on the first deploy: without them the catalog keeps the technical identifier and the response returns `warnings[]` with a hint. If the field is already set, the value you send is **not applied** — the response says so in a separate `warnings[]` entry and points at [`PATCH /v1/infra/servers/:id`](/docs/infra/servers/update), the only rename endpoint. A separate entry arrives when the APPLIED value lost its non-ASCII characters on the way in (Windows PowerShell without explicit UTF-8 serialization is the usual case): the deploy itself is normal, and the value reaches the card as is. A dropped field gets no such entry — it never reached the card. A repeat deploy with the same value is silent too: nothing is written, and fixing the charset of a string already on the card goes through the rename endpoint.
- **Non-ASCII characters in `displayName` and `description` from Windows PowerShell.** Sent without explicit UTF-8 serialization, they reach the catalog card as question marks (`?`): the bytes are lost on the client side, before the request is sent. A ready-to-run call with `UTF8.GetBytes` — [Windows / PowerShell and UTF-8](/docs/infra#windows-powershell-and-utf-8).
- **A non-root `healthPath` means the deploy also checks the root.** Once the given path answers, the platform probes `/` — the address Bitrix24 opens. A 5xx at the root ends the deploy with an error on the `healthcheck` step: a light endpoint such as `/api/health` answers 200 even when the application page is down, and such a deploy used to report success. A 4xx does not abort the deploy — an `app_root` step arrives with status `warning`: a server with no page at the root (an API-only backend, for instance) is a working shape. A probe that never reports back is also a `warning`, not an error. With the default `healthPath` (`/`) no extra request is made.
- **A non-root `healthPath` with a root `appUrl` produces a `warnings[]` entry.** Bitrix24 opens the app at the linked application's `appUrl`, and the platform fills it with the bare subdomain origin — no path. If the app answers ONLY on the subpath, the health check is green (it probed exactly that subpath) while the Bitrix24 frame opens the root, where the app is not. The deploy response carries a `warnings[]` hint — act on it **only if the root is genuinely not served**: a probe on a subpath while the UI lives at the root (`healthPath: "/health"`) is a normal, correct shape, and moving that path into `appUrl` would instead break the embed (the frame would open a JSON endpoint). If a subpath is genuinely unavoidable, fix both halves: deploy there AND move the same subpath into `appUrl` via `PATCH /v1/apps/{id}` (id from `GET /v1/apps`, matched on `appUrl`). The check runs only on `POST /v1/infra/servers/:id/deploy`, and only when `appUrl` points at THIS server's subdomain; server-creation responses do not carry it. **Why the root is preferred and not merely tidier:** after a user signs in, the gateway returns them to the subdomain root rather than to the subpath, so an app with a subpath in `appUrl` lands on its own 404 on the first visit of each session; a hash-router app additionally loses `location.hash` on the wake screen. This is a known gateway limitation and is being fixed separately.
- **A `warning` status on the `tunnel_routing` step does not mean the deploy failed.** After a successful `healthcheck`, the platform verifies that the Gateway tunnel routes public traffic to the right port (`set_port` + agent probe). If the port has not been detected yet or the agent runs in fixed-port mode, the step gets status `warning` (not `error`) and the deploy is not aborted. The application's public URL may serve the Black Hole placeholder page for about a minute while the agent's port scanner detects the listening port. The placeholder goes away on its own **if the previous port was released**. If a live process still answers on the previous port, the scanner deliberately keeps it and will not switch on its own — set the port explicitly via [`PATCH /port`](/docs/infra/deploy/port) or stop that process. If the agent has no port auto-detection at all (`agentError: NO_SCANNER` in the step text), call [`/repair`](/docs/infra/lifecycle/repair).
- **The application code is passed via `source.content`, not via `start`/`install`/`preStart`.** The command fields (`start`, `install`, `preStart`) are short shell commands with a 5000-character limit each (`command` in [`/exec`](./exec.md) is 10000). They are not meant for file contents: the base64 of a whole archive does not fit there and gets truncated. Put the code itself (tar.gz / zip, including a single file) in `source.content` — a base64 string of the archive, up to 96 MB per request body, which is about 72 MB of archive — or in `source.url`, and leave only the start command in `start` (for example `node server.js`). An archive larger than 72 MB cannot be sent inline and is refused with `413 INLINE_SOURCE_TOO_LARGE`: send it as a link in `source.url` or as a saved version in `source.versionId`, both of which accept up to 500 MB of archive. On a Galaxy application both paths are available where link deploys are enabled for you — otherwise shrinking the archive is the only option left.
- **Four strategies for `.env`.** The file is written to `{extractTo}/.env` (by default `/opt/app/.env`) and with `cleanDeploy: true` is deleted before the next deploy:
  1. Pass `env` on every `/deploy` — it is regenerated automatically (recommended).
  2. `preserveEnv: true` — the platform reads `.env` before the clean and writes it back at the `env` step. If the deploy aborts after the snapshot was created and the clean began, for example on archive download or dependency install, the saved copy is put back on disk best-effort. A `runtime` failure happens before the snapshot and clean: the platform does not read the previous `.env` or upload a copy back, and the file stays in place. Useful when the variables are not at hand at redeploy time. An `env` passed in the same request wins: the file is written anew and the saved copy is discarded. Dedicated virtual machine only (`kind: "STANDALONE"`).
  3. Store `.env` outside `extractTo` — for example, `/var/lib/myapp/.env`. That directory is not touched by `cleanDeploy`. Specify the explicit path in `start`.
  4. `cleanDeploy: false` — an accumulating (merge) deploy, `.env` survives, but stale files from previous deploys remain along with it.

  The strategies above apply to a dedicated virtual machine. A Galaxy application's `env` behavior is covered in a separate entry below.
- **The platform writes the wake windows' timezone into `.env` itself.** If a dedicated virtual machine (`kind: "STANDALONE"`) has enabled [scheduled wake](/docs/infra/wake-schedules) windows and all of them declare one and the same timezone, the deploy adds to `.env` a `TZ` variable carrying that zone — the application receives it along with the other variables and uses it to compute local time. Your own `TZ` passed in the `env` field wins: the platform does not override it. With `preserveEnv: true` the zone is appended to the file restored at the `env` step when that file carried no `TZ` line. If the deploy aborts after the snapshot was created and the clean began, but before the `env` step, the saved copy is put back as it was, with no zone appended — the next successful deploy adds it. A `runtime` failure happens earlier: the previous file stays in place without being read or uploaded back. If the windows declare different zones, or there are no windows at all, `TZ` is not set. For a Galaxy application (`kind: "GALAXY_APP"`) the zone is not injected — the container receives only the variables from the `env` field of that request.
- **Scheduled wake brings the server up; it does not keep it running.** If [scheduled wake](/docs/infra/wake-schedules) is available for your Bitrix24 account, an enabled recurring window is a good fit for work at a fixed time, when a task with no inbound activity finishes before the server's explicit numeric `data.sleepAfterMinutes`; the schedule does not shorten that numeric timeout. Unless the server is in the `SCHEDULE` run mode, current writes reject setting `null` while windows are enabled, and reject schedule creation or update while `null` is stored — even for a disabled window. If an older record still holds `null` together with an effective schedule, the platform's post-window fallback applies — 15 minutes by default. Inbound HTTP requests and SSH activity reset the timer; cron, background processes, and the application's outbound traffic do not. Continuous or longer work requires `{ "sleepAfterMinutes": null }` and no enabled wake window. The absence of a deploy warning — with `null`, or with windows already created — does not itself promise no-sleep behavior.
- **An app that itself calls the Vibecode V1 API needs its own key.** If the deployed app talks to the Vibecode V1 API (`/v1/deals`, `/v1/users`, etc.), it sends its own personal `vibe_api_` key in the `X-Api-Key` header. The platform does not issue that key automatically: the user creates it in the [API Keys](/docs/keys-auth) section (`/keys`; for a read-only dashboard, in READONLY mode with minimal scopes) and passes it to the app via the `env` parameter (for example `env: { "VIBE_API_KEY": "<key>" }`); the app reads `process.env.VIBE_API_KEY` and sends it as `X-Api-Key`. A `401` (`MISSING_API_KEY` / `INVALID_API_KEY`) means the app carries no valid key, not a plan problem. A personal key talks to Bitrix24 as its owner — keep the server `accessPolicy` at `OWNER_ONLY`, otherwise other users would see the key owner's data. If the app is embedded in Bitrix24 as a placement and shows per-user data, that is a different auth flow, see [App authorization](/docs/infra/app-runtime).
- **Automatic loading of `.env` in systemd.** The unit declares `EnvironmentFile=-{extractTo}/.env` (the `-` sign makes the file optional). The application gets the variables itself via systemd. But with `systemd: false` or commands from `install`/`preStart`, env is not loaded — pass it explicitly.
- **If `dependencies` and `devDependencies` are mixed**, `npm install --production` will not install packages from `devDependencies`. For cron scripts with `tsx`, `ts-node`, etc., drop `--production` or add them separately via `preStart`.
- **Where `pip` fetches packages from.** The platform sets a default package index — the canonical PyPI (`pypi.org`). The setting is machine-wide, so it covers the automatic `requirements.txt` install, your own command in the `install` field, and any `pip` you run by hand through `exec`. **Your choice wins over ours:** an `--index-url` in `requirements.txt` or in the install command overrides the default. A `PIP_INDEX_URL` from the deploy `env` does NOT affect a Galaxy build — the environment reaches the container at runtime, while dependencies are installed earlier, during the image build; on a dedicated virtual machine it does work. If package immutability matters to you, use `--require-hashes` with pinned hashes — that is the only mechanism protecting you from substitution on the index side. `poetry` and `uv` do not read our setting; point them at an index using their own configuration mechanism.
- **Where npm and apt fetch packages in Galaxy.** Before the build, the platform sets machine-wide npm and Debian configuration, so both automatic installation and the `install` command see it; deploy `env` does not affect those build steps. An npm command's own registry or custom Debian sources override the default.
- **A repeat deploy with the same `runtime` reinstalls it.** Omit the `runtime` parameter on repeat deploys — it saves 45+ seconds. The first deploy with `runtime` is required to install it.
- **The health check may not succeed in time.** Up to 10 attempts at a 3-second interval — that is about 30 seconds in total. If the application takes longer to start, add a `/health` endpoint that responds 200 immediately, even before warm-up, or move the long initialization into `preStart`.
- **The `start` command stays in the foreground and holds the listener itself.** On a dedicated virtual machine the platform runs it as a systemd unit of type `simple`: the unit counts as alive exactly as long as the process it launched has not exited. A command that hands the work to a background daemon and exits does not hold the unit — once it exits, the service moves to `inactive` and systemd stops whatever processes it left behind. The listener disappears along with them. Write a command that never returns control: `node server.js`, `python3 app.py`, `nginx -g 'daemon off;'`. Which Python command your runtime needs is listed in [List of runtimes](./runtimes.md). A process manager inside `start` (`pm2 start server.js --name app && pm2 save`, and any other form that launches and then exits) does not work under this scheme, even when the runtime installed that manager for you.

  Such a command has two outcomes, both from the same cause. First, the health check finds no listener and the deploy fails with `attempt 10/10: curl exit=7`. Second, the check reaches the app while the background process is still alive, so the deploy finishes successfully and the app stops answering a few seconds later: the deploy response is green and there is no application on the port any more. What tells the two outcomes apart is the deploy result itself: a red deploy carrying `attempt 10/10: curl exit=7` is the first one, a green deploy whose app goes silent a few seconds later is the second. The `portOwner` field of the `healthcheck` step does NOT separate them — `unknown` arrives in both: in the first the port-ownership probe never ran at all (there was no status code in the 200–399 range), in the second it did run and could not name your unit as the owner. `service` arrives only for a command that holds the port itself. For a `start` command that launches the process directly, `unknown` on a green deploy most often means the port is held by a leftover background process rather than by the unit, but it does not prove it: the same `unknown` arrives when the ownership probe could not complete — its call broke on a timeout or on the transport — and then the unit does hold the port and the application is fine. What tells the two apart is the application itself: one that still answers a minute after the deploy is not explained by a leftover process. Do not draw that conclusion for an application running on Docker Compose: there a green deploy with `unknown` happens with no leftover process at all — the ownership probe never ran because on the last attempt the project's container was not yet found settled. The full list of causes for `unknown` is in the response fields table.

- **A successful deploy means the application answered a GET.** The `healthcheck` and `app_root` steps request `healthPath` and the root with the GET method. The deploy does not check whether POST, PATCH and DELETE routes are ready: an application that answers them with an error still passes the deploy green. Check such routes with your own call after the deploy.

- **Background applications must also listen on port 3000.** The `healthcheck` step runs on every deploy — even if the application does not serve HTTP traffic (a bot that only polls events, a cron daemon, a queue worker). Without a process listening on port 3000, the step retries and finishes with the error `attempt 10/10: curl exit=7` (failed to connect). Alongside the main logic, bring up a minimal HTTP server that responds with code 200 to `GET /`:

    Node.js:

    ```javascript
    require('http').createServer((_req, res) => res.end('ok')).listen(3000)
    // then — the application's main logic
    ```

    Python:

    ```python
    import http.server, threading

    class Health(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b'ok')
        def log_message(self, *args):
            pass

    threading.Thread(
        target=lambda: http.server.HTTPServer(('', 3000), Health).serve_forever(),
        daemon=True,
    ).start()
    # then — the application's main logic
    ```
- **A 15-minute lock.** A single `/deploy` blocks `/exec` and other `/deploy` calls. If the previous `/deploy` failed at the `healthcheck` step or the client dropped the connection on timeout, subsequent `/exec` and `/deploy` calls return `409 EXEC_BUSY` until the lock expires automatically — that is, up to 15 minutes from the start of the interrupted deploy. The `409 EXEC_BUSY` response carries a `Retry-After` header and `retryable: true` / `retryAfter` (seconds) fields — this is a short poll interval, so keep retrying with it; the full upper bound until the lock clears is in `error.hint.autoExpiresInSeconds`. What comes next depends on the KIND of the stuck lock. A leaked exec mutex is cured by the guarded [`POST /v1/infra/servers/:id/unstick`](/docs/infra/servers/unstick): a holder record with `op: exec` that outlived its TTL is released without `force`. The server lock of an interrupted DEPLOY is not that — a legitimate deploy outlives its TTL, so there `/unstick` without `force` answers `409` while the lock is observed and the wait stands. **Important:** both calls require an independently confirmed idle server — its state and its logs: the tunnel bounce kills whatever the agent is running. The unconditional [`DELETE /v1/infra/servers/:id/lock`](./lock.md) cuts the wait only on the replica that HOLDS the lock — the broadcast deliberately spares a live foreign deploy — and it stays the last step, allowed only once the server is independently confirmed idle. There is no need to recreate the server.
- **A long deploy and HTTP-client timeouts.** A full cycle — especially with a `runtime` install and a large `npm install` / `pip install` — takes several minutes. In JSON mode the server holds the connection, sending HTTP 200 + chunked spaces every 15 s until it is ready (so `Content-Length` is absent and the response looks "hung"). The spaces go inside the already-opened JSON object, so the body looks like JSON (`{`) from its very first byte. The platform window is **660 seconds**: the connection is not held longer than that. Set the HTTP-client timeout to **at least 690 seconds** — strictly above the platform window, because a timeout equal to it races the platform itself and the response can be lost. If the client drops the connection earlier (for example, `curl --max-time 120`, or an SDK default, which in some clients is 300 seconds), the request still keeps running on the server. Symptoms of such a drop: the client got `200` and the headers while the body is empty or does not parse as JSON — the response broke off on the keep-open spaces. A drop during the wake looks different: there is no response at all, because the status and the headers reach the client only after the machine is up. The operation itself is already visible through [`GET /v1/infra/servers/:id/operations`](./operations.md): find the run there, then use its id to read the addressable [Deploy outcome](./operation-status.md). If the wake returned a refusal, its HTTP response carries the id in `X-Vibe-Operation-Id` and `error.operationId`; after a fail-open insert there may be no id, and that does not prove that the run never started. Subsequent `/exec` and `/deploy` calls return `409 EXEC_BUSY` until the server-side part finishes or the 15-minute lock expires. What to do: (a) in curl — `--max-time 690` or higher, (b) in any SDK — set the request timeout to ≥ 690 s for this endpoint, (c) or use `?stream=true` (SSE) — in that mode events arrive line by line as the steps progress, and proxy and SDK timeouts behave more reliably. [`POST /v1/infra/servers/:id/unstick`](/docs/infra/servers/unstick) cannot clear a server lock left behind by such a drop: it is guarded, answers `409` without `force` while the lock is observed, and clears a lock by itself only when that lock is an exec mutex past its own TTL. What remains is to wait the 15 minutes out or move to the unconditional [`DELETE /lock`](./lock.md) — it stays the last step and only once the server is independently confirmed idle. Do not re-send the deploy before reading that outcome: a second run would land on top of an application that may already be up. **A sleeping server adds up to 6.5 minutes to that window:** the spaces start only after the wake, so the first deploy after sleep meets up to 6.5 minutes of silence before the platform's 660-second window even begins. Set the timeout with that margin, or wake the machine in advance with [`/wake`](/docs/infra/lifecycle/wake) and `wait=true`. A sleeping server with a live plan-change operation (`resize`) returns `409 EXEC_BUSY` before waking; wait for that operation to finish. Do not use manual wake to bypass resize.
- **`apt-get` in `preStart` works without `sudo`.** The agent and the deploy commands run as `root` — there is no need to write `sudo` and it will not cause an error.
- **The app itself runs under an unprivileged account.** Deploy commands (`install`, `preStart`) and `/exec` run as `root`, but the application process that systemd starts runs as the dedicated `vibeapp` user. It owns the deploy directory (`extractTo`, `/opt/app` by default) and `$HOME` points there, so writing inside its own directory, `npm`/`pip` caches and data files all work unchanged. What stops working: writing outside its own directory unless that directory is declared in `dataDirs` (see the next item), connecting to a database reachable only through a root-owned socket (`MySQL` with `auth_socket`), and running `docker` from the `start` command. Ports below 1024 do work — the platform grants a separate permission for them.
- **A state directory outside `extractTo` is declared with the `dataDirs` field.** A clean deploy wipes `extractTo`, so durable data belongs in a separate directory — `/opt/data`, for example. That directory is created by `root` (your `install` or `preStart` step, or the platform itself), so under an unprivileged start the app cannot write there until the directory is declared. Pass `"dataDirs": ["/opt/data/state"]` in the deploy body — the platform then creates the directory and hands it to `vibeapp` on every deploy, and hands ownership back if hardening is rolled back. The path must be absolute, already normalized and inside `/opt`, `/srv` or `/var/lib`; the bare roots (`/opt`, `/var/lib`) are refused with `INVALID_DATA_DIRS` before the deploy starts. Up to eight directories can be declared.

  What is handed over is **the directory itself, not its contents** — enough for the app to create its own files there. Files `root` put inside earlier stay with `root`, so the app cannot rewrite them. If the app has to modify exactly those (a database deployed in advance, a tree baked into the image), add `"dataDirsRecursive": true`.

  **What matters about permissions.** Whoever owns a directory can delete or replace any file in it, including files `root` put there. So do not declare a directory that holds scripts you later run as `root` (a database restore script you launch through `/exec`, for instance) or credentials the app is not meant to touch. Keep those in a directory you did not declare — which is exactly why the examples declare the `/opt/data/state` subdirectory rather than `/opt/data` itself: `/opt/data` is where our own recipes put service scripts that are run as `root`, along with a password file with `0600` permissions. For the same reason `dataDirsRecursive` is not accepted for `/opt/data` at all.

  If the deploy is not hardened (`"hardening": "off"`, `"systemd": false`, or an `extractTo` outside the accepted roots) the field does nothing — the app already runs with administrator privileges. A separate `data_dirs` step then returns `warning` and says so explicitly. On a Galaxy application the field is ignored: durable data lives in the `/data` volume there.
- **A file uploaded through `/upload` after a deploy belongs to `root`.** The upload is performed by the agent with administrator privileges, while the deploy directory belongs to `vibeapp` once the deploy finishes — so the app cannot append to or overwrite such a file. If the app is meant to modify an uploaded file, ship it inside the source archive or hand ownership over in the next deploy's `preStart`.

  If the app fails its `healthcheck` under this mode, the deploy **automatically** restores the previous mode (both the unit and directory ownership) and finishes successfully, adding a `hardening` step with `warning` status and the journal tail explaining why. You will not lose a deploy to hardening.

  The rollback restores privileges and directory ownership, and that is all it does: it does not lift the requirement on the `start` command. A command that launches a daemon and exits behaves the same way after the rollback — the service dies together with it, whichever account it runs under. A `hardening` step in the response reports a change of run mode, not the reason the application is not answering.

  To skip the attempt outright, pass `"hardening": "off"` in the deploy body. That is the right call for apps that genuinely need administrator privileges: `nginx` as the `start` command, `MySQL` over the root socket, or a Docker-driven start. A Docker Compose app brought up by the `start` command passes in this mode too: the `stop_existing` step stops the app's container, and after the rollback the `start` command launches it again with administrator privileges.
- **The `install`, `preStart` and `start` commands run through the POSIX shell `/bin/sh`.** Write them in POSIX-compatible syntax. To stop on the first error, use `set -e`; for conditions, use `[ ... ]`. The `set -o pipefail` option is available only in bash — if you need it, call bash explicitly: `bash -c 'set -o pipefail; commands'`. The same applies to a separate script launched from `start` — begin it with the line `#!/bin/bash` or run it via `bash script.sh`.
- **`docker-compose-plugin` is missing from the standard Ubuntu repositories — install Docker via the official script.** The `docker-compose-plugin` package is a Docker CE component from the docker.com repository, which is not in the standard Ubuntu 24.04 apt sources. `apt-get install docker-compose-plugin` fails with the error `Unable to locate package`. The correct way to install Docker Engine + Compose V2 in a single command:
  ```
  "preStart": "curl -fsSL https://get.docker.com | sh && systemctl enable --now docker"
  ```
  After that `docker compose` (no hyphen) is available as a plugin. If you only need Compose V1 (deprecated, for compatibility only), it is in the Ubuntu repos: `apt-get install -y docker-compose`.
- **`cleanDeploy: true` cleans only `extractTo`, not the whole system.** By default the `/opt/app` directory is deleted before extracting the new archive. System packages (`apt`), databases, files in `/var/lib/`, `/etc/`, `/root/` are not affected — they survive any number of repeat deploys.
- **System packages persist across sleep/wake, reboot, and repair.** All these operations keep the virtual machine disk intact. There is no need to reinstall software via `preStart` on every deploy — doing it once is enough. Subsequent deploys without `runtime` and without the needed `preStart` commands find the packages in place.
- **`/etc/systemd/system/` is writable.** You can create custom systemd units via `preStart` or `/exec`: `systemctl daemon-reload && systemctl enable --now myservice`. The standard deploy unit (`app.service`) does not conflict with user units when the names differ.
- **When you change `serviceName` between deploys, stop the old service manually.** The `stop_existing` step stops only the service with the current `serviceName`. If the previous deploy used the default name `app` and the next one uses `dedup`, then `systemctl stop dedup` does not find the previous `app.service`. The previous service keeps running and holds the app port — 3000 by default. The new service cannot bind to the port and goes into a restart loop. The `healthcheck` step recognizes this state, including the case where the previous process answers the request with code 200: the deploy fails. The error message names the port number and the process holding the port, and in the restart-loop case the state of the service as well. It does not always offer the command to free the port (`fuser -k <port>/tcp`): if the new service stayed up and the port is held by a process that was already on it before the deploy began, the message says exactly that and gives no command, because the holder may be a service you need. The platform does not terminate an unrelated process. The one exception is a Docker Compose container of the app itself, which the `stop_existing` step stops. Free the port with `fuser -k <port>/tcp`, stop the previous service with `systemctl stop <old-name>.service` via [`/exec`](./exec.md), or restore the previous `serviceName` value, and then repeat the deploy. One caveat: when the port diagnostics could not run, a response with status 200–399 counts as success and the deploy completes successfully — unless the check of the Docker Compose containers from the `extractTo` directory has already found one of the failures listed for the `healthcheck` step.
- **Use `debug: true` when `/deploy` and the manual path diverge.** It adds a `debug_info` step with `sha256sum` of files and `file -i` — it helps compare the state of `/deploy` with manual `/upload` + `/exec` byte by byte. A typical case — Tailwind v4 oxide fails on unexpected UTF-8.
- **The archive type is detected automatically, there is no separate format field.** For `source.content` the platform reads the signature of the first bytes (`PK` → zip, `1F 8B` → tar.gz, `BZ` → tar.bz2), for `source.url` — the path extension. You do not need to pass the type as an explicit field — and there is no field to pass it in. **Do not rely on an unrecognized archive being parsed.** The recognized format now also selects how the archive is delivered to the machine, so "the format was not recognized, we will read it as tar.gz" turned from a promise into a last-resort fallback on the older delivery path. Package the archive as tar.gz (`tar -czf app.tar.gz -C ./my-app .`) or zip (`zip -r app.zip .`) — then the result does not depend on which delivery path the platform picks.
- **tar.gz is preferred for uploading code.** Both formats extract, but a zip built with PowerShell's `Compress-Archive` stores paths with a backslash `\`. Nested files from such an archive may end up in the root of `extractTo` instead of their folders, and part of the code will not reach the right place. The `normalize_windows_paths` step brings such paths back to normal, but to avoid the issue, pack the code into tar.gz: `tar -czf app.tar.gz -C ./my-app .`. If you need zip — pack it with a Unix tool: `zip -r app.zip .`.
- **In the new mode, the platform downloads a dedicated VM's `source.url` archive.** For servers enrolled in the rollout, it validates the destination of every connection and redirect, streams the response, and relays bounded chunks to the VM over the existing tunnel. Source availability therefore does not depend on that VM's egress or NAT path. Transient DNS/TCP/TLS/HTTP/body failures get up to four attempts with backoff inside a shared five-minute budget; extraction runs once after size and SHA-256 checks. Before enrollment, or when the agent does not support relay, a compatible VM-download path is used; while the kill switch is off it preserves the historical URL, timeout, and error contract. Stable sanitized failures apply to the new mode.
- **Large inline archives compete for a slot.** A `source.content` larger than ~10 KB holds the request in memory for the whole deploy, so only a few such requests run concurrently (by default 2 per backend). When you exceed it, a `429` is returned with a hint to switch to `source.url` or `source.versionId`: they do not consume this inline-memory slot, but URL relay has its own bounded pool. When saturated it returns `DOWNLOAD_RELAY_BUSY` after a successful `runtime`, when one was passed, but before `stop_existing` and `clean`: the orchestrator has not stopped the unit or cleaned the application tree. The runtime installer may already have changed host dependencies, and those changes are not rolled back. Small inline archives and `versionId` do not take a slot.
- **A deploy from an external URL requires a source snapshot or an explicit opt-out.** When [source storage](/docs/source-storage) is enabled for your Bitrix24 account, a deploy with a `source.url` that does not point at Vibecode storage returns `409 SNAPSHOT_REQUIRED` — this way the application version history is not lost on a deploy from a third-party address. There are two options. Pass the `X-Skip-Source-Snapshot: <reason ≤200 characters>` header — the deploy continues without saving a snapshot. Or first upload the archive via `POST /v1/apps/:id/sources` and deploy it via `{ "source": { "versionId": "vN" } }` — then the platform saves the version. A source from storage itself — `source.versionId` or `source.content` — does not require this header. More details — [Source storage](/docs/source-storage).
- **Nginx in `static`, `php83`, and `php83-mysql`.** On a dedicated virtual machine the runtime installs the package before stopping the previous application, but it does not start the system `nginx.service` or leave it newly enabled. For `static`, the listener is started by the managed `app.service`; PHP recipes keep their own start commands.
- **`static`** — a runtime for single-page applications and static sites. Send an archive containing the ready build, `runtime: "static"`, and the desired `port` (default `3000`); `start` is optional and ignored in every placement. The remaining behavior is placement-specific. On a dedicated virtual machine with the usual `systemd: true`, the platform creates the nginx configuration, serves `extractTo` (default `/opt/app`), and runs nginx under an unprivileged account; the document root must be a nested application directory under `/opt` or `/srv`, while all paths under `/var/lib` and `/opt/data` are refused. With `systemd: false`, the platform creates neither an nginx configuration nor a unit: the pre-existing unit must listen on the selected `port` itself. In Galaxy, files always live at `/opt/app` inside the container, `extractTo` and standalone settings are ignored, and the container runs nginx with its fixed command. The managed configurations in both placements fall back to `index.html` for client-side routes, check the complete path chain for symlinks, and do not serve hidden files including `.env`. Multipart deploys still require the `port` field.
- **A Galaxy application does not need its own `Dockerfile` — the platform generates it itself.** From the deploy fields `runtime` and `port`, and for non-static runtimes `start`, the platform builds a `Dockerfile` with the runtime base image and an `EXPOSE <port>` line. Non-static `CMD` is derived from `start`; static uses the platform nginx command and ignores `start`. Putting your own `Dockerfile` in the archive is not required and not needed. This applies only to a Galaxy application — the `createdVia` field equals `galaxy`.
- **The build error `published no host port for <port>` means the container did not stay up on `port`, not that `EXPOSE` is missing.** For a Galaxy application this text arrives when the container crashed on start, the `start` command is wrong, or the application listens on a different port — that is, nothing responds on `port`. Fix `start` and `port` so the application actually listens on `port`. There is no need to add `EXPOSE` manually: the platform already set it from `port`.
- **A Galaxy application's `env` variables are injected into the container at startup, not baked into the image.** The platform passes them with the `docker run --env` flag when the container starts, so secrets from `env` do not end up in the image layers and are not visible in its history. This applies only to a Galaxy application — the `createdVia` field equals `galaxy`.
- **A Galaxy application deploy without user `env` resets environment variables and returns `warnings[]`.** The container is recreated on every deploy, and variables are passed only as startup arguments from the current request. If `/deploy` omits `env`, sends `{}`, or sends only the platform-owned `PORT`, the owner's container starts without previous user variables; the successful response warns about the reset and asks for the complete `env` set on every deploy. `preserveEnv` is ignored for a Galaxy application and does not hide the warning. A development-team member's deploy is the exception: the platform preserves variables already set by the owner, so there is no reset warning; `env` sent by the member is ignored with a separate warning. The `.env`-preservation strategies — `preserveEnv`, a file outside `extractTo`, `cleanDeploy: false` — are available only for a dedicated virtual machine.
- **The galaxy host downloads an archive from source storage itself, and the cause of a failure is named separately.** When the deploy runs from a `source.versionId` snapshot and the archive is packed as tar.gz, the machine downloads it itself and checks the size and the checksum against the saved version right there. If the download or the unpacking fails, `502 GALAXY_APP_BUILD_FAILED` arrives and the `buildLog` tail opens with a cause marker: `UPLOAD_DOWNLOAD_FAILED` — it did not download, `UPLOAD_EXTRACT_FAILED` — it did not unpack, `UPLOAD_NO_SPACE` — the host is out of space. The markers land in `error.category` as `SOURCE_FETCH_FAILED`, `SOURCE_EXTRACT_FAILED` and `RESOURCE` — what to do about each cause is in the category table below. The one thing the table does not say: when the host is out of space, shrinking the archive is pointless — it is not your files that filled the disk. The host-side download is enabled per account, while zip archives and archives above the link size cap take the previous path — there the cause arrives as a single message about the failed extraction.
- **Galaxy build and start errors arrive with the cause diagnosed.** For `502 GALAXY_APP_BUILD_FAILED` and `502 GALAXY_APP_START_FAILED` the platform parses the build log and, when the cause is recognized, adds two fields to the `error` object: `category` — a machine code for the cause, `buildHint` — a recommendation in the API key's language. The possible `category` values:

  | Value | What happened |
  |-------|---------------|
  | `NO_SOURCE` | The application slot was created, the code was never uploaded |
  | `DEPLOY_INCOMPLETE` | The code upload did not finish — the build was interrupted or the host was unreachable |
  | `SOURCE_FETCH_FAILED` | The archive could not be downloaded to the machine — the link expired, the connection dropped, or the contents did not match the saved version. Send the deploy again: a fresh link is issued |
  | `SOURCE_EXTRACT_FAILED` | The archive downloaded but could not be unpacked — a corrupt archive, or members pointing outside the extraction directory |
  | `EMPTY_CONTEXT` | The archive extracted to an empty context — it holds no application files |
  | `BUILD_TIMEOUT` | The build did not finish within its time limit |
  | `PORT_NOT_PUBLISHED` | The container did not bind to `port` — the application exited right at start |
  | `RUNTIME_CRASH` | The container built but did not stay alive after start |
  | `RUNTIME_OOM` | The container ran out of memory after start — it hit the galaxy memory limit |
  | `BUILD_OOM` | The image build ran out of memory — the install step hit the build memory limit, which is half of the galaxy host memory. Prepare heavy compilation ahead of deployment or choose a larger galaxy plan. The failure is terminal — the platform does not retry it |
  | `RESOURCE` | The host ran out of free disk space — either while the archive was being downloaded to the machine or during the build itself. Over HTTP a build-time shortage arrives under its own code, `502 GALAXY_LOW_DISK`, which is retryable and does not mark the application broken, while the server record keeps this category |
  | `BASE_IMAGE_UNAVAILABLE` | The image registry was unreachable at the step that fetches the build base image. This is transient — the same deploy goes through on a retry |
  | `HOST_UNAVAILABLE` | The galaxy host was unreachable during the build |
  | `NO_PACKAGE_JSON` | An install command was set explicitly, but there is no `package.json` at the archive root |
  | `MODULE_NOT_FOUND` | A module was not found — the entry path in `start`, or a missing dependency |
  | `INSTALL_FAILED` | Dependency installation failed |
  | `INSTALL_AUTH` | The package registry returned 401 or 403 — no access to a private package |
  | `INSTALL_REGISTRY_UNAVAILABLE` | The Python or Node.js package registry, or the Debian package source, did not answer while installing a dependency or system package. The failure is terminal — the platform does not retry it |
  | `NATIVE_PREBUILD_UNAVAILABLE` | The prebuilt artifact of a dependency shipping compiled code could not be downloaded: the external source did not answer, and the app image provides no way to build it from source. The condition is temporary — the same deploy passes on a retry. The failure is terminal: the platform does not retry it — send the deploy again yourself in a few minutes |

  When the cause could not be recognized, neither field is present — only `code`, `message` and `buildLog` remain. Branch in code on `category`, show `buildHint` to the user.
- **The `502 GALAXY_HOST_UNREACHABLE` response has an `error.hint` field with a recovery plan.** It is an object of four strings: `reason` — why the host is unreachable right now, `recovery` — what to do, `recoveryAction` — the next safe call, `note` — a caveat that the host status in the listing can lag behind the real tunnel state. Deploy has two branches. With `retryable: false`, the build was dispatched and its durable outcome is `unknown`: `recoveryAction` points to the operation list; do not repeat the deploy blindly. Without `retryable: false`, the refusal happened before build dispatch and the hint permits a retry after the tunnel recovers. **There is no need to delete and recreate the application** — the slot, container and `/data` volume are intact.
- **The deploy wakes a sleeping dedicated virtual machine itself and waits for it within the same request.** If the server (`kind: "STANDALONE"`) is in `sleeping` status, the deploy starts the wake and waits for readiness for up to 6.5 minutes: 5 minutes for the machine to start and the tunnel to connect, then another 90 seconds of waiting without a reboot. If it makes it in time, the deploy continues in the same request and a separate [`/wake`](/docs/infra/lifecycle/wake) call is not needed. If it does not, `503 WAKE_TIMEOUT` arrives **and the server returns to `sleeping`** — a repeat request is safe and starts the wake over. The deploy does not bypass a wake block: with a frozen balance the request is rejected before the deploy with `402 ACCOUNT_FROZEN`, and for the other causes — for example, expired platform access or an administrative block — `403 SERVER_WAKE_BLOCKED` arrives. In both cases a retry will not help until that state changes. [`/exec`](./exec.md), [`/upload`](./upload.md) and [`/logs`](./logs.md) behave the same way. A sleeping server with a live plan-change operation (`resize`) returns `409 EXEC_BUSY` before waking; wait for that operation to finish.
- **The deploy wakes a sleeping galaxy itself, and the first request may not have enough time.** If the galaxy host is asleep, the deploy starts the wake in the background and waits for the tunnel to connect for about 4 minutes. A cold host usually fits into that window and the deploy goes through in a single request. If the wait times out before the build is dispatched, `502 GALAXY_HOST_UNREACHABLE` arrives without `retryable: false`, **and the host keeps booting** — the platform does not roll the wake back; follow `error.hint` after the tunnel recovers. If the response carries `retryable: false`, the build was dispatched and its outcome is `unknown`: read the operation list, server state, and logs first; do not repeat the deploy blindly. A host frozen for billing or stopped manually is not woken — a retry will not help there until that state changes.

## Upload path busy

Another upload is using the source upload path. A synchronous deploy returns `409 GALAXY_UPLOAD_BUSY` with `error.retryable: true`, `error.retryAfter: 10`, a matching `Retry-After: 10` header and `error.hint`. Wait at least 10 seconds and retry the same `POST /v1/infra/servers/:id/deploy` for this app.

The refusal itself preserves the app status and the version already running. When a build starts in the background after server creation, a new app initially remains `provisioning`: its server response and dashboard card show a `buildHint` recommending the same deploy again. If the attempt remains stuck for 20 minutes, the next check within another 5 minutes marks the app `error`, preserving the busy-path reason and `buildHint`. Retry the same deploy after this transition too: a new attempt returns this slot to `provisioning`. The `/data` contents are preserved. A source build started after server creation is not retried automatically. Hermes retries its upload no sooner than 3 minutes later; the existing 30-minute wait limit ends the attempt while retaining the busy-path reason and hint. Do not delete the app, release a lock or call `/unstick`.

## See also

- [Runtimes list](./runtimes.md)
- [Run a command](./exec.md)
- [Service logs](./logs.md)
- [Release a stuck lock](./lock.md)
- [Set the port](./port.md)
- [Scheduled wake](/docs/infra/wake-schedules)
- [Source storage](/docs/source-storage)
