
## Полный деплой приложения

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

Полный цикл деплоя за один запрос: установка рантайма → остановка предыдущего сервиса → скачивание архива → распаковка → установка зависимостей → запись `.env` → команды `preStart` → создание systemd-юнита → запуск → проверка работоспособности. Используйте вместо цепочки `/upload` + `/exec` — это быстрее. Если шаг `runtime` завершается ошибкой, деплой не выдаёт команду остановки, не очищает и не заменяет дерево приложения, не читает и не перезаписывает `.env`. Установщик рантайма работает с правами `root` и может изменить системные зависимости хоста, поэтому непрерывная работа прежнего приложения не гарантируется. После успешного `runtime` последующие ошибки сохраняют прежнее поведение: нового отката нет, юнит может остаться остановленным, а дерево приложения — уже заменённым. По умолчанию ответ — JSON (рекомендуется для AI-агентов): платформа отвечает `200` перед первым шагом выкладки, а тело дописывает по завершении всех шагов — всё это время соединение удерживается открытым. Спящая машина сначала просыпается, и до конца пробуждения клиент не получает ни статуса, ни заголовков, поэтому таймаут HTTP-клиента должен покрывать и пробуждение, и саму выкладку. Для построчного прогресса передайте `?stream=true` — сервер вернёт поток SSE (Server-Sent Events) с событиями на каждом шаге. При живой операции смены тарифа (`resize`) спящий сервер получает `409 EXEC_BUSY` до пробуждения; дождитесь её завершения.

**Для AI-агентов и MCP-клиентов обязательно используйте JSON-режим (без `?stream=true`).** Официальный MCP-клиент Вайбкод (инструмент `manage_server_deploy`) снимает этот параметр сам и всегда работает в JSON-режиме.

## Секреты приложения

Значения секретов задают в карточке приложения его владелец и администратор портала. Как завести имя и как значение применяется без деплоя — на странице [Секреты приложения](/docs/applications/app-secrets). При деплое значение секрета перекрывает одноимённое поле `env`, а имя без значения не перекрывает явно переданный `env`. `PORT` задаёт платформа. Ответ `warnings` перечисляет проигнорированные имена и секреты без значения со ссылкой на карточку, без самих значений.

На отдельной машине платформа записывает временный файл и затем заменяет им `/var/lib/vibe/app-secrets/<служба>.env` от root с правами `0600`, вне каталога приложения. Каталог `/var/lib/vibe/app-secrets` принадлежит root и имеет права `0700`. Пути `extractTo` и `dataDirs` не должны пересекаться с этим каталогом (включая его родительские и вложенные каталоги). Дополнение `/etc/systemd/system/<служба>.service.d/50-vibe-secrets.conf` подключает его через `EnvironmentFile=-/var/lib/vibe/app-secrets/<служба>.env`. Авторский `.env` не меняется. Удалённые имена исчезают из файла. Команды `install` и `preStart` секретов не получают.

При `systemd: false` файл и дополнение также записываются. Чтобы подключить их самостоятельно, используйте systemd-службу с тем же `serviceName` и выполните `systemctl daemon-reload` и перезапуск службы. Для другого менеджера процессов нужен загрузчик формата systemd `EnvironmentFile`; не выполняйте этот файл как shell-скрипт. Доступ к файлу требует root.

В Галактике секреты добавляются только в окружение запускаемого контейнера. При переносе, восстановлении и замене ключа они доставляются заново; ранее доставленные, а затем удалённые имена передаются пустыми, чтобы не вернуться из образа. При восстановлении в новое приложение набор зашифрованных секретов и история имён копируются в его карточку при совпадении владельца и портала.

Значения не возвращаются через API и не публикуются в журнале деплоя. **Тот, кто выполняет команды на машине или код приложения, может прочитать окружение процесса.** Не давайте этот доступ тем, кому нельзя читать секреты.

Многострочные значения храните в base64: закодируйте значение в одну строку перед сохранением секрета и декодируйте внутри приложения. Платформа не декодирует base64 автоматически.


## Деплой и доступ приложения к порталу

Ключ, которым вызван `POST /v1/infra/servers/:id/deploy`, не переносится автоматически в окружение приложения. Успешная выкладка и проверка работоспособности не проверяют доступ приложения к данным портала.

При успешном деплое с OAuth-app-ключом ответ добавляет одно предупреждение об этой границе: в `warnings` JSON-ответа отдельной виртуальной машины и Galaxy-приложения, а в SSE — в `warnings` события `done`. Это напоминание о настройке авторизации, а не результат проверки учётных данных runtime: оно приходит независимо от переданного `env`, `preserveEnv` и выбранного рантайма, включая `static`. Успех деплоя и прежние предупреждения сохраняются. При личном ключе или ошибке деплоя это предупреждение не добавляется.

Для работы от имени посетителя передайте приложению ключ того же OAuth-приложения, которое выдало пользовательскую сессию. Сервер приложения читает входящий `X-Vibe-Authorization` и передаёт его значение в `Authorization` исходящего запроса к `/v1/*`, вместе с ключом приложения в `X-Api-Key`. Для headless-сервиса явно передайте личный ключ через `env`, ограничьте его права минимально необходимыми scopes и оставьте доступ к серверу в `OWNER_ONLY`. Подробности обеих схем — [Контракт приложения](/docs/infra/app-runtime).

`BITRIX_API_KEY` и `BITRIX_API_BASE_URL`, если их читает ваш код, — выбранные клиентом имена переменных. Платформа не создаёт их автоматически: передайте нужные значения явно и настройте их чтение в приложении. Отсутствие этих имён в `env` само по себе не доказывает отсутствие учётных данных у приложения.

## Galaxy-приложение — деплой сразу после создания

Если сервер — galaxy-приложение (поле `createdVia` равно `galaxy` в ответе [`POST /v1/infra/servers`](../servers/create.md)), деплой запускают **сразу после создания**, не дожидаясь `blackholeStatus: "CONNECTED"` — контейнер собирается самим деплоем. Модель размещения, жизненный цикл и стоимость — на странице [Galaxy-приложение](../galaxy.md).

Отличия деплоя для galaxy-приложения:

- `runtime` **обязателен**. `start` обязателен для всех рантаймов, кроме `static`; для `static` поле необязательно и игнорируется. Без `runtime` запрос вернёт `400 GALAXY_DEPLOY_RUNTIME_REQUIRED`.
- Источник — встроенный `source.content` (архив в base64). Варианты `source.url` и `source.versionId` доступны там, где платформа включила для вас выкладку по ссылке. Иначе они отклоняются с `400 GALAXY_DEPLOY_CONTENT_ONLY`. Ссылку стоит предпочесть, когда она есть: архив по ней скачивает сам хост, поэтому в теле запроса ничего крупного не едет и потолок на встроенный архив не действует. Ссылка обязана вести в [хранилище исходников](/docs/source-storage) — сохранённая версия и подписанная ссылка на неё подходят, сторонний адрес отклоняется с `400 GALAXY_SOURCE_URL_NOT_ALLOWED`. У отдельной виртуальной машины такого ограничения нет.
- `Dockerfile` в архиве не требуется — платформа сгенерирует его сама из `runtime` и `port`, а для рантаймов кроме `static` также из `start`. Static всегда получает платформенную команду nginx. Если положить свой `Dockerfile` в архив, он будет проигнорирован (платформа всегда генерирует свой). См. «Известные особенности» ниже.
- Рабочая директория контейнера — **`/opt/app`** (та же, что и `extractTo` у обычной виртуальной машины): код копируется туда, поэтому работают и относительная команда (`start: "node server.js"`), и абсолютный путь (`start: "node /opt/app/server.js"`). Путь `/app` тоже ведёт на `/opt/app` — это символьная ссылка для обратной совместимости. То есть один и тот же `start` подходит и для VM-, и для galaxy-деплоя.
- Деплой идёт по id самого приложения (`kind` равен `GALAXY_APP`). Тот же запрос с id несущей машины (`kind` равен `GALAXY`) отклоняется — `400 GALAXY_HOST_NOT_A_DEPLOY_TARGET`.
- Поля отдельной виртуальной машины к galaxy-приложению не применяются: `preStart`, `systemd`, `serviceName`, `cleanDeploy`, `extractTo`, `hardening`, `preserveEnv`, `dataDirs`, `dataDirsRecursive`. Запрос с ними проходит, значения не действуют. Команды, которые нужно выполнить до старта приложения, задавайте в `install` — он выполняется при сборке контейнера. Поле `healthPath` действует и задаёт путь проверки готовности внутри контейнера. Пути в `extractTo` и `dataDirs` при этом всё равно проверяются: недопустимое значение вернёт `400 INVALID_EXTRACT_TO` или `400 INVALID_DATA_DIRS`, и тот же код придёт на `dataDirsRecursive` без `dataDirs`.

Жизненный цикл galaxy-приложения — на странице [Galaxy-приложение](../galaxy.md). Сценарий «деплой одним запросом при создании» — на странице [Создать сервер](../servers/create.md).

## Параметры

| Параметр | В | Тип | Обяз. | По умолч. | Описание |
|----------|---|-----|:-----:|-----------|----------|
| `id` | path | string (UUID) | да | — | ID BLACKHOLE-сервера, `status: running`, `blackholeStatus: CONNECTED` |
| `stream` | query | string | нет | — | `true` — потоковый режим SSE. Для JSON-ответа параметр не передавайте: JSON возвращается по умолчанию |

## Поля запроса (body — JSON)

| Поле | Тип | Обяз. | По умолч. | Описание |
|------|-----|:-----:|-----------|----------|
| `source` | object | **да** | — | Источник кода — ровно один из трёх вариантов ниже (`url`, `content` или `versionId`) |
| `source.url` | string | да (или `content` / `versionId`) | — | HTTPS-URL архива с кодом. До 500 МБ |
| `source.content` | string | да (или `url` / `versionId`) | — | Base64-строка архива. До 96 МБ на тело запроса, то есть около 72 МБ самого архива: base64 тяжелее исходных байт примерно на треть. Тело сверх потолка отклоняется кодом `413 INLINE_SOURCE_TOO_LARGE`. Архив крупнее сохраняйте версией и разворачивайте через `source.versionId` |
| `source.versionId` | string | да (или `url` / `content`) | — | ID снапшота из депо приложения, например `v3` — см. [Хранилище исходников](/docs/source-storage) |
| `start` | string | кроме `static` | — | Команда запуска приложения (пример: `cd /opt/app && node server.js`). Для `runtime: "static"` поле необязательно и игнорируется: процессом управляет платформа |
| `port` | number | нет | `3000` | Порт, на котором приложение слушает. **Всегда 3000** для BLACKHOLE — если не передан, подставляется 3000. (В multipart-режиме порт указывать обязательно, см. таблицу ниже.) Платформа сама сообщает этот номер приложению в переменной окружения `PORT` — см. «Платформа сообщает порт в `PORT`» ниже. |
| `extractTo` | string | нет | `/opt/app` | Директория распаковки. Проверяется до начала деплоя — недопустимый путь отклоняется с `400 INVALID_EXTRACT_TO` (см. «Ошибки»). Managed standalone static (`systemd: true`) принимает только вложенный прикладной каталог под `/opt` или `/srv`; все пути под `/var/lib` и `/opt/data` отвергаются. При `systemd: false` и для остальных рантаймов действуют общие правила пути |
| `runtime` | string | нет | — | ID рантайма: `node24`, `python311`, `php83`, `static` и т. д. Список рантаймов и команда запуска для каждого — [`GET /v1/infra/runtimes`](./runtimes.md). Сервер создаётся из стандартного образа Ubuntu 24.04 — без `runtime` на нём нет ни `node`, ни `python3.11`, ни `php8.3`, и `start` упадёт с `command not found`. Рантайм ставит свою команду: после `python311` на отдельной виртуальной машине доступен `python3.11`, а команды `python` там нет. После `python312` — команда `python`, а `python3` остаётся системным интерпретатором без пакетов приложения. Альтернатива — поставить тулчейн вручную через `preStart` (см. ниже). |
| `install` | string | нет | — | Команда установки зависимостей (до 5000 символов) |
| `preStart` | string | нет | — | Команда **до** запуска сервиса: миграции БД, seed-скрипты, сборка, ручная установка системных пакетов (например, `apt-get update && apt-get install -y nodejs npm` если не передан `runtime`). До 5000 символов. Работает только для отдельной виртуальной машины (`kind: "STANDALONE"`) |
| `env` | object | нет | — | Переменные окружения `{ "KEY": "value" }` — будут записаны в `.env` и загружены systemd. Ключ `PORT` — **зарезервирован платформой**: она подставляет в него значение поля `port`, а присланное вами значение перекрывает и сообщает об этом в `warnings` ответа. Значение каждой переменной должно быть однострочным: перевод строки или нулевой байт в значении отклоняется с `400 VALIDATION_ERROR`, а путь в сообщении указывает нарушивший ключ — `env.<КЛЮЧ>`. Многострочное значение, например ключ в формате PEM, передавайте одной строкой, заменив переносы двумя символами `\n`, и восстанавливайте их на стороне приложения |
| `displayName` | string | нет | — | Понятное имя приложения (2–100 символов) для карточки каталога Битрикс24. **Указывайте всегда** — иначе в каталог попадёт технический идентификатор, а ответ вернёт предупреждение |
| `description` | string | нет | — | Короткое описание, что делает приложение, для карточки каталога Битрикс24 (до 500 символов) |
| `changelog` | string | нет | — | Что изменилось в этой версии (обычный текст, до 2000 символов). При выходе новой версии текст публикуется подписчикам в ленту канала приложения в мессенджере Битрикс24. Заполняйте одной-двумя строками «что нового». Если не передать — в ленту уйдёт только номер версии («Обновление до vN»). Ключ, который не меняет данные Битрикс24, анонс в ленту не публикует: заметка версии сохраняется, карточка и иконка публикуются, в `warnings` приходит предупреждение |
| `baseVersionId` | string | зависит | — | Версия исходников, на которой основана эта выкладка, например `"v12"` — та, которую вы скачали перед правкой. Если в хранилище исходников сервера появилась версия новее, деплой отклоняется с `409 SOURCE_VERSION_STALE`: в теле придёт номер актуальной версии и ссылка на её скачивание. **Обязательна, как только в хранилище есть хотя бы одна версия и либо последнюю версию сохранил другой разработчик, либо у сервера есть команда разработки**: деплой без поля отвечает `409 BASE_VERSION_REQUIRED` и называет актуальную версию. Для первой выкладки в пустое хранилище поле не требуется; без команды оно также остаётся необязательным, пока последняя версия ваша, но чужая последняя версия делает его обязательным и на сервере без команды. Значение должно называть версию, с которой вы действительно начали: номер, переписанный из текста ошибки, подтверждает слияние, которого не было. Номер впереди хранилища (версии с таким номером не существует) отклоняется с `400 BASE_VERSION_NOT_FOUND` |
| `systemd` | boolean | нет | `true` | Создавать systemd-юнит для автозапуска |
| `cleanDeploy` | boolean | нет | `true` | Очистить `extractTo` перед распаковкой. Установите `false` для merge-деплоя |
| `preserveEnv` | boolean | нет | `false` | Сохранить существующий `.env` при очистке: файл читается до удаления `extractTo` и записывается обратно на шаге `env`. Возврат сохранённой копии по мере возможности применяется только после создания снимка и перехода к очистке. Ошибка `runtime` происходит раньше: прежний `.env` остаётся на месте, не читается и не загружается обратно. Если в этом же деплое передан `env`, он побеждает, а сохранённая копия отбрасывается. Работает только для отдельной виртуальной машины (`kind: "STANDALONE"`) |
| `serviceName` | string | нет | `app` | Имя systemd-юнита. Только `a-z`, `0-9`, `-`. Создаёт `{serviceName}.service`. При смене имени между деплоями — см. «Известные особенности» |
| `healthPath` | string | нет | `/` | Путь для проверки работоспособности — например, `/health`, `/api/status`. Только безопасные символы. Не-корневой путь допустим, пока приложение отвечает и на `/`: после успешной проверки этого пути деплой опрашивает и корень — ответ 5xx там останавливает деплой ошибкой, ответ 4xx даёт шаг `app_root` со статусом `warning`. Если приложение отвечает ТОЛЬКО на подпуть — см. «Известные особенности» |
| `debug` | boolean | нет | `false` | Добавить диагностический шаг `debug_info` — `ls -la`, `sha256sum` файлов, `file -i` для отладки шагов сборки |
| `hardening` | string | нет | `auto` | Режим запуска приложения. `auto` — запустить под непривилегированной учётной записью и вернуть прежний режим автоматически, если приложение не пройдёт проверку работоспособности. `off` — сразу запустить с правами администратора. См. «Известные особенности» |
| `dataDirs` | array | нет | — | Каталоги состояния вне `extractTo`, в которые должно писать само приложение, — например `["/opt/data"]`. На хардненном деплое платформа создаёт каждый и передаёт сам каталог учётной записи приложения. До восьми путей, каждый — абсолютный нормализованный путь внутри `/opt`, `/srv` или `/var/lib` с собственным именем каталога, иначе `400 INVALID_DATA_DIRS`. См. «Известные особенности» |
| `dataDirsRecursive` | boolean | нет | `false` | Передать и содержимое каталогов из `dataDirs`, а не только сами каталоги. Нужно лишь для заранее развёрнутого дерева, которое приложение обязано переписывать. Для `/opt/data` не принимается. См. «Известные особенности» |

## Поля запроса (body — multipart/form-data)

Альтернатива JSON — загрузка архива без base64. Полезно из скриптов bash/PowerShell.

| Поле формы | Тип | Обяз. | Описание |
|------------|-----|:-----:|----------|
| `file` | файл | **да** | Архив (tar.gz / zip). Потолок — 500 МБ, пока архив уходит в хранилище исходников потоком, и около 72 МБ, когда потоковая запись недоступна и архив читается в память: тогда отказ приходит кодом `413 INLINE_SOURCE_TOO_LARGE`. Три условия потоковой записи — в «Известных особенностях». Архив принимается и под именем поля `archive` |
| `start` | string | кроме `static` | Команда запуска. Для `runtime: "static"` поле необязательно и игнорируется |
| `port` | string | **да** | Порт (строкой: `"3000"`) |
| `extractTo` | string | нет | По умолчанию `/opt/app`. Та же проверка, что и в JSON-варианте: managed standalone static принимает только вложенный прикладной каталог под `/opt` или `/srv` и отвергает `/var/lib`, а при `systemd: false` и для остальных рантаймов действуют общие правила; недопустимый путь отклоняется с `400 INVALID_EXTRACT_TO` |
| `runtime` | string | нет | ID рантайма |
| `install` | string | нет | Команда установки |
| `displayName` | string | нет | Понятное имя приложения для каталога Битрикс24 |
| `description` | string | нет | Короткое описание для каталога Битрикс24 |
| `changelog` | string | нет | Что нового в этой версии (до 2000 символов) — публикуется в ленту канала приложения в мессенджере Битрикс24. Ключ, который не меняет данные Битрикс24, анонс не публикует: заметка версии сохраняется, в `warnings` приходит предупреждение |
| `baseVersionId` | string | зависит | То же поле, что в JSON-режиме: версия исходников, на которой основана выкладка (`"v12"`). Более новая версия в хранилище отклоняется с `409 SOURCE_VERSION_STALE`. Когда хранилище непустое, пропущенное поле отклоняется с `409 BASE_VERSION_REQUIRED`, если последнюю версию сохранил другой разработчик или у сервера есть команда разработки; номер впереди хранилища отклоняется с `400 BASE_VERSION_NOT_FOUND` |
| `preStart` | string | нет | Команда до запуска. Работает только для отдельной виртуальной машины (`kind: "STANDALONE"`) |
| `env` | string | нет | JSON-строка: `{"KEY":"value"}`. Это текстовое поле формы — если прислать `env` файлом или Blob-частью, запрос вернёт `400 VALIDATION_ERROR`. Значения переменных — однострочные, как и в JSON-режиме |
| `systemd` | string | нет | `"true"` / `"false"` |
| `cleanDeploy` | string | нет | **При multipart по умолчанию `"false"`** (merge). Укажите `"true"` для чистого деплоя |
| `preserveEnv` | string | нет | `"true"` — сохранить существующий `.env` при очистке и записать его обратно на шаге `env`. Возврат по мере возможности применяется только после создания снимка и перехода к очистке. Ошибка `runtime` происходит раньше: прежний файл не читается и остаётся на месте без обратной загрузки |
| `serviceName` | string | нет | Имя systemd-юнита |
| `healthPath` | string | нет | Путь для проверки работоспособности |
| `debug` | string | нет | `"true"` — добавить диагностический шаг `debug_info` |
| `hardening` | string | нет | `"auto"` / `"off"` — режим запуска приложения. См. «Известные особенности» |
| `dataDirs` | string | нет | JSON-массив строкой: `["/opt/data"]`. То же поле и та же проверка, что в JSON-варианте. Не-массив отклоняется с `400 VALIDATION_ERROR`, недопустимый путь — с `400 INVALID_DATA_DIRS` |
| `dataDirsRecursive` | string | нет | `"true"` / `"false"`. Читаются только эти два литерала, опечатка не включает режим |

## Шаги деплоя

Шаги выполняются последовательно. Если какой-то провалился — деплой останавливается.

| Шаг | Условие | Что делает | Таймаут |
|-----|---------|------------|---------|
| `runtime` | `runtime` передан | Устанавливает рантайм до `stop_existing`. При повторном деплое с тем же — переустанавливает. Если установка завершается ошибкой, снимок `.env`, остановка и очистка ещё не начинались. Если установка прошла, но установщик сообщил о себе — например, один из источников пакетов отложен в сторону, чтобы apt оставался читаемым, или рантайм поставлен из запасного источника, — шаг возвращает статус `warning`, текст сообщений лежит в `stdout`, а деплой продолжается и завершается успешно. Такие состояния обычно разбираются сами на следующем деплое | 300с |
| `stop_existing` | всегда | `systemctl stop {serviceName}` — останавливает сервис с текущим именем `serviceName` и освобождает порт от оставшихся процессов этого же деплоя. Если порт публикует запущенный контейнер Docker Compose самого приложения — проекта, чья папка в метке Compose `com.docker.compose.project.working_dir` совпадает с `extractTo` или лежит внутри него, — шаг останавливает этот контейнер командой `docker stop` и называет его в `stdout`. Поднять контейнер снова должна команда `start`. Другие посторонние процессы не завершаются: шаг возвращает статус `warning`, деплой продолжается. Столько же — если остановку не удалось подтвердить: результат не пришёл за отведённое время ЛИБО сервер ответил, что остановить не смог. В обоих случаях шаг возвращает `warning` с текстом «исход неизвестен» и деплой идёт дальше — этот шаг деплой не прерывает | 105с |
| `clean` | `cleanDeploy: true` | Удаляет всё в `extractTo`. Если сервер не выполнил команду (например, занят другой), шаг возвращает `error` и называет причину — деплой останавливается здесь, а не позже на `download` с непонятным сообщением | 30с |
| `download` | всегда | Получает и распаковывает архив. На отдельной VM в rollout надёжной загрузки платформа скачивает `source.url` сама и передаёт через туннель. Временные сетевые отказы получают до 4 попыток с backoff в общем бюджете 5 минут. До включения сохраняется прежняя загрузка с VM. Постоянные ошибки URL-политики, HTTP, размера, целостности и распаковки не повторяются только в новом режиме. `source.content` сохраняет прежние 2 попытки | До 5 мин на получение `source.url` в новом режиме. Распаковка имеет отдельный бюджет |
| `cleanup_metadata` | после extract | Удаляет macOS-сайдкары (`._*`, `.DS_Store`) — молча если архив чистый. Здесь же удаляются небезопасные символические ссылки. Если разбор ссылок не успел пройти дерево целиком, шаг получает статус `warning` с пояснением в `stdout`, а выкладка продолжается | ~120с |
| `normalize_windows_paths` | после extract | Приводит пути с обратной косой чертой `\` из zip, собранного PowerShell `Compress-Archive`, к обычному виду. Несёт тот же разбор символических ссылок и тот же статус `warning`, если он не успел пройти дерево целиком | ~120с |
| `debug_info` | `debug: true` | Диагностика: `ls -la`, `sha256sum` файлов, `file -i`, локаль. Читает до 32 КБ в `stdout` | — |
| `install` | `install` передан | Запускает команду установки зависимостей | 300с |
| `env` | `env` передан либо `preserveEnv: true` сохранил прежний файл | Пишет `.env` в `extractTo` с правами `0600`. При восстановлении сохранённой копии шаг возвращает пояснение в `stdout`. Если после создания снимка и перехода к очистке деплой оборвался до этого шага, копия возвращается на диск без отдельного шага в ответе. При ошибке `runtime` снимка ещё нет, прежний файл не читается, обратная загрузка не выполняется, а файл остаётся на месте | — |
| `platform_env` | всегда | Пишет платформенный файл `.vibe-platform.env` в `extractTo` с правами `0600` — в нём одна переменная `PORT`. Ваш `.env` этот шаг не читает и не перезаписывает. Если файл записать не удалось, шаг завершается ошибкой и деплой прерывается: без него приложение запустилось бы, не зная своего порта. Исключение — `systemd: false`: юнит там ваш, платформенный файл всё равно ничего не загружает, поэтому шаг возвращает `warning` и деплой продолжается | — |
| `pre_start` | `preStart` передан | Запускает команду до старта сервиса. Для миграций БД, seed-скриптов, сборки | 300с |
| `service_user` | `systemd: true`, `hardening` не равен `off`, а `extractTo` — вложенный каталог `/opt`, `/srv` или `/var/lib`, записанный без завершающей косой черты и без сегментов `.` и `..`. По любому другому пути хардненинг не применяется — шаг возвращает статус `warning`, а приложение продолжает работать с правами администратора | Создаёт непривилегированную учётную запись `vibeapp` и передаёт ей владение каталогом `extractTo`, чтобы приложение работало без прав администратора. Если учётную запись создать или передать владение не удалось — статус `warning`, деплой продолжается с прежними правами | 120с |
| `data_dirs` | В теле деплоя объявлено поле `dataDirs` | Создаёт объявленные каталоги и передаёт их учётной записи приложения, а в `stdout` перечисляет каждый путь и режим передачи. `ok` — переданы все объявленные каталоги. `warning` — какой-то не удалось создать или передать, либо деплой оказался не хардненным и передавать было нечего. Деплой при этом продолжается и хардненинг не снимается. Шага нет вовсе, если поле не передавали | 30с на каталог (120с в рекурсивном режиме) |
| `systemd` | `systemd: true` | Создаёт `{serviceName}.service` и перечитывает конфигурацию systemd (`systemctl daemon-reload`) | 30с |
| `start` | всегда | `systemctl enable --now {serviceName}` | 30с |
| `hardening` | хардненный запуск откачен | Эмитится **после** `start` и оценки healthcheck (порядок в `data.steps[]` — `start` → `hardening` → `healthcheck`), потому что предупреждение об откате возможно только после неудачного старта под `vibeapp`. Сообщает, что приложение вернулось к работе с правами администратора. Приходит и при УСПЕШНОМ деплое, и при ПРОВАЛЬНОМ — всегда перед завершающим шагом `healthcheck`. Несёт машинное поле `hardeningRollback` (см. таблицу полей ответа). Статус всегда `warning`. В `stdout` — причина отката и что сделать для закрепления хардненного режима. Хвост журнала сервиса добавляется только на успешном деплое | — |
| `healthcheck` | всегда | Проверка работоспособности — до 10 попыток `curl localhost:{port}{healthPath}` с интервалом 3 секунды, засчитывается ответ со статусом 200–399 от сервиса `serviceName`. Ответ 200–399 от постороннего процесса, удерживающего порт, шаг не засчитывает и завершается ошибкой. Если приложение запущено через Docker Compose из каталога `extractTo`, ответ 200–399 тоже не засчитывается, если контейнер этого проекта Compose перезапускается — даже если он перезапускается давно, ещё с момента до выкладки, — или если контейнер, стартовавший во время этой выкладки, после старта перезапускался, остановился с ненулевым кодом, перешёл в состояние `dead` или помечен Docker как `unhealthy`: шаг завершается ошибкой, которая называет сервис Compose, его состояние и последние строки журнала контейнера. **Важно:** статус этого шага значит разное на разных видах серверов: на отдельной виртуальной машине (`kind: "STANDALONE"`) ответ 4xx/5xx даёт `error` и деплой падает, у galaxy-приложения (`kind: "GALAXY_APP"`) — `warning`, и деплой считается прошедшим | ~30с |
| `app_root` | `healthPath` отличается от `/` | Опрашивает корень приложения — адрес, который открывает Битрикс24. `ok` — корень ответил 200–399. `warning` — ответил 4xx либо проверка не дошла: деплой продолжается, пояснение в `stdout`. Ответ 5xx корнем деплой не продолжает: он завершается ошибкой на шаге `healthcheck`, и шага `app_root` в ответе нет. При `healthPath` по умолчанию (`/`) шага нет вовсе — корень уже проверен | ~10с |
| `tunnel_routing` | всегда | Проверяет, что туннель Gateway маршрутизирует на нужный порт. У обычного сервера — командой агенту плюс опрос, у сервера с закреплённым портом — перезаписью настроек агента (см. `port_pin`). Статус `warning` (не `error`) — проверка не прошла, деплой не прерывается | ~10с |
| `port_pin` | сервер закреплён либо закрепление включено для портала | Записывает порт в настройки агента на машине, чтобы он пережил пробуждение и починку туннеля. `ok` — порт в настройках, `warning` — записать не удалось, приложение при этом работает, но после пробуждения агент снова выберет порт сам. Подробности — [Изменить порт](./port.md) | ~20с |

## Примеры

Примеры с внешним `source.url` содержат заголовок `X-Skip-Source-Snapshot`: при включённом хранилище исходников деплой со стороннего адреса без него возвращает `409 SNAPSHOT_REQUIRED`. Подробности и альтернатива через хранилище — в разделе «Известные особенности».

### curl — личный ключ

```bash
# JSON, Node.js с миграциями
curl -X POST "https://vibecode.bitrix24.tech/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 — локальный архив
tar -czf app.tar.gz -C ./my-app .
curl -X POST "https://vibecode.bitrix24.tech/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-архив (минимальный набор полей)

```bash
# Канонический inline-деплой: достаточно source.content + start.
# Тип архива определяется автоматически по сигнатуре первых байт —
# tar.gz, zip и tar.bz2 поддерживаются, отдельное поле формата не нужно.
B64=$(base64 < app.tar.gz | tr -d '\n')
curl -X POST "https://vibecode.bitrix24.tech/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
  }"
```

Обязательны только два поля: `source` (один из `content` / `url` / `versionId`) и `start`. Архив можно завернуть и в zip (`zip -r app.zip .`), и в tar.gz (`tar -czf app.tar.gz -C ./my-app .`) — платформа распознаёт оба по содержимому.

### curl — произвольный стек без рантайма

```bash
# Деплой без runtime — любое ПО ставится через preStart
curl -X POST "https://vibecode.bitrix24.tech/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-приложение

```bash
curl -X POST "https://vibecode.bitrix24.tech/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 — личный ключ

```javascript
const res = await fetch(
  `https://vibecode.bitrix24.tech/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(`Приложение живёт: ${body.data.appUrl}`)
} else {
  console.error(`Упал на шаге ${body.error.step}: ${body.error.message}`)
}
```

### JavaScript — OAuth-приложение

```javascript
await fetch(
  `https://vibecode.bitrix24.tech/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,
    }),
  }
)
```

## Поля ответа

| Поле | Тип | Описание |
|------|-----|----------|
| `success` | boolean | `true` — все шаги выполнены, приложение отвечает на проверку работоспособности |
| `data.steps` | array | Массив шагов процесса деплоя, в порядке выполнения |
| `data.steps[].step` | string | Имя шага: `runtime`, `stop_existing`, `download`, `install`, `env`, `platform_env`, `pre_start`, `service_user`, `data_dirs`, `systemd`, `start`, `healthcheck`, `app_root`, `hardening`, `tunnel_routing`, `port_pin`, и т. д. У galaxy-приложения (`kind: "GALAXY_APP"`) имён два — `build` и `healthcheck` |
| `data.steps[].status` | string | `ok` — успешно, `warning` — шаг не сделал задуманное, но деплой продолжается (объяснение в `stdout` или `stderr` — так сообщают о себе `stop_existing`, `normalize_windows_paths`, `cleanup_metadata`, `service_user`, `start`, `hardening`, `app_root`, `tunnel_routing`), `error` — провал (только последний шаг может иметь этот статус). У шага `runtime` `warning` означает другое: установка ПРОШЛА, но установщик рантайма сообщил о себе — читайте `stdout` |
| `data.steps[].duration` | number | Длительность шага в секундах. Присутствует не у всех шагов — только у тех, которые сами отмеряют время выполнения (`runtime`, `install`, `pre_start`, `service_user`, `normalize_windows_paths`, `cleanup_metadata`, `debug_info`) |
| `data.steps[].stderr` | string | Стандартный поток ошибок (`stderr`) шага при `status: "error"`, а также при `status: "warning"` у шага `start` — там он несёт исходную причину, по которой сервис не запустился с первой попытки |
| `data.steps[].stdout` | string | Стандартный вывод (`stdout`), заполняется для шага `debug_info` (до 32 КБ), а также несёт объяснение у шагов со статусом `warning` — в том числе `runtime`, `service_user` и `hardening` |
| `data.steps[].causeCode` | string | Только для ошибки `download`: стабильная машинная причина. Возможные значения: `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 | Только вместе с `causeCode`: можно ли безопасно повторить тот же запрос. Для `DOWNLOAD_COMMIT_UNCONFIRMED` и `DOWNLOAD_OUTCOME_UNCONFIRMED` всегда `false`: сначала прочитайте [список операций](./operations.md), не повторяйте deploy вслепую. Не выводите значение из текста `message` |
| `data.steps[].attempts` | number | Число реально начатых попыток получить `source.url`: `0` — отказ до скачивания, `1…4` — источник запрашивался. Поле отсутствует, если старый агент не вернул терминальный результат и число попыток доказать нельзя |
| `data.steps[].httpCode` | number | Только у шага `healthcheck`. HTTP-код ПОСЛЕДНЕЙ попытки проверки, а у galaxy-приложения — код ответа приложения на проверку. **Поле отсутствует**, если ни одна попытка кода не получила (соединение не установилось) — отсутствие и значит «кода не было», ноль или `null` вместо него не приходят. У galaxy-приложения поле теряется ещё в одном случае: деплой, доведённый до конца после обрыва связи с хостом, теряет код вместе с журналом сборки. Это факт о попытке, а НЕ вердикт: у шага со `status: "error"` поле законно может быть `200` |
| `data.steps[].healthPath` | string | Только у шага `healthcheck`. Путь, который фактически проверяли (уже с учётом значения по умолчанию `/`). У galaxy-приложения — путь, по которому платформа обращалась к приложению |
| `data.steps[].portOwner` | string | Только у шага `healthcheck`. Кому принадлежит порт по последней успешной попытке: `service` — вашему сервису (проверено), `foreign` — постороннему процессу, `unknown` — **владелец не установлен**. У `unknown` четыре причины. Первая: зонд владения не запускался, потому что ответ был вне диапазона 200–399. Вторая: ответ был В диапазоне 200–399, но зонд всё равно не запускался — раньше него, на той же попытке, проверка контейнеров Docker Compose этой выкладки признала контейнер проекта неподнявшимся либо ещё не устоявшимся (см. шаг `healthcheck` в таблице шагов), и до порта попытка не дошла. Третья: ответ был В диапазоне 200–399 и зонд запускался, но не отработал — его вызов оборвался по таймауту или на транспорте, и о владельце попытка не узнала ничего; деплой при этом может остаться зелёным, и тогда `unknown` значит только «проверить не удалось», а не «порт держит посторонний процесс». Четвёртая: зонд отработал и не смог назвать владельца — так отвечает порт, который держит не сам юнит, а оставленный вашей командой `start` фоновый процесс. Поэтому сочетание `httpCode` 200–399 с `portOwner: "unknown"` само по себе НЕ означает, что порт держит посторонний процесс: у приложения на Compose это чаще всего вторая причина, и при `status: "error"` фактическую причину называет текст ошибки шага. Значение НЕ означает «порт свободен» |
| `data.steps[].hardeningRollback` | string | Только у шага `hardening`. `completed` — откат завершён: юнит и владение каталогом `extractTo` вернулись к администратору, приложение работает с полными правами. `incomplete` — откат начался и не завершился: приложение МОЖЕТ по-прежнему работать под `vibeapp`. Поле описывает возврат ЮНИТА и владения `extractTo`, и только их |
| `data.serviceName` | string | Имя systemd-юнита (эхо параметра или `"app"`) |
| `data.status` | string | `"running"` после успешного деплоя |
| `data.appUrl` | string | HTTPS-адрес приложения: `https://{subdomain}.vibecode.bitrix24.tech` |
| `data.sleepAfterMinutes` | number \| null | Сохранённый таймаут авто-сна в минутах на момент начала деплоя. Только в успешном ответе отдельной виртуальной машины (`kind: "STANDALONE"`), независимо от наличия предупреждения или окон пробуждения. `null` означает отсутствие сохранённого таймаута простоя. На невытесняемом тарифе без назначенного рабочего расписания сервер не засыпает по простою даже при наличии окон пробуждения. Если сон по расписанию применим, при `null` используется платформенный таймаут — по умолчанию 15 минут |
| `data.source` | object | Итог автосохранения исходников. Только при `success: true`. В SSE-режиме приходит отдельным событием `event: source`. Неудавшийся деплой блока не содержит, но версия в хранилище может уже существовать — см. «Известные особенности». Полный контракт — [Хранилище исходников](/docs/source-storage) |
| `data.source.autoSaved` | boolean | `true` — снимок исходников сохранён или привязан к деплою |
| `data.source.savedVersionId` | string | Версия `vN` сохранённого снимка. Присутствует при `autoSaved: true` |
| `data.source.skippedReason` | string \| null | Причина, по которой снимок не сохранён, либо `null`. Значения — [Хранилище исходников](/docs/source-storage) |
| `data.operationId` | string | Идентификатор операции выкладки. Тот же, что в заголовке ответа `X-Vibe-Operation-Id`. По нему исход читается постфактум — [Исход выкладки](./operation-status.md). На отдельной машине запись заводится до конвейера, но у спящей машины блокирующее пробуждение завершается раньше, чем id доезжает по этому соединению; у galaxy-приложения запись заводится после захвата mutex, а id приходит только с терминальным ответом. Отсутствует, если соединение оборвалось до доставки либо платформа не смогла завести запись |
| `warnings` | array\<string\> | Присутствует, только если есть предупреждения — например, `displayName`/`description` не переданы либо не применены (см. «Известные особенности») либо шаг `runtime` завершился со статусом `warning` — тогда строка указывает, что сообщения установщика рантайма лежат в `stdout` этого шага. Успешный деплой с OAuth-app-ключом также добавляет предупреждение о настройке доступа приложения к порталу (см. выше). В SSE-режиме приходит в том же `event: done` |

## Заголовки ответа

На отдельной виртуальной машине (`kind: "STANDALONE"`) выкладка заводит запись операции ДО конвейера. На уже доступной машине id доезжает до клиента до начала работы. На спящей машине запись уже видна в списке операций сервера, но блокирующее пробуждение завершается раньше, чем по этому соединению приходят HTTP-статус, заголовки или SSE-кадр. При живом resize отказ приходит до создания записи операции и не несёт operationId:

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

Тот же идентификатор дублируется в теле (`data.operationId` при успехе, `error.operationId` при отказе), а в режиме `?stream=true` приходит ещё и первым кадром до первого `event: step` — после завершения возможного блокирующего пробуждения:

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

Кадр нужен потому, что браузерный `EventSource` заголовков ответа не отдаёт вовсе.

У galaxy-приложения (`kind: "GALAXY_APP"`) запись заводится после захвата mutex приложения, при входе в первый шаг. Идентификатор приходит в заголовке и в терминальном JSON-теле; раннего SSE-кадра у этой ветки нет. Если соединение оборвалось раньше ответа, найдите запуск через [`GET /v1/infra/servers/:id/operations`](./operations.md).

**Отсутствие заголовка НЕ означает, что выкладка не запускалась.** Связь с отдельной спящей машиной могла оборваться во время пробуждения, связь с galaxy могла оборваться до терминального ответа, либо платформа могла не суметь завести запись и продолжить без идентификатора. Повторять выкладку на этом основании нельзя — второй запуск ляжет поверх приложения, которое уже могло подняться. Сначала прочитайте [последние операции сервера](./operations.md), состояние и логи; адресный исход — [Исход выкладки](./operation-status.md).

**Заголовки приходят до конвейера, после возможного блокирующего пробуждения; тело — в конце.** В режиме JSON на отдельной виртуальной машине (`kind: "STANDALONE"`) платформа отдаёт `200` и заголовки перед первым шагом, а тело ответа дописывает, когда выкладка завершена: всё это время соединение удерживается открытым пробелами. Если машина спала, деплой сначала создаёт операцию, затем будит машину, и до конца пробуждения — до 6.5 минут — клиент не получает ни статуса, ни заголовков, ни самого идентификатора по этому соединению. Пока запрос молчит, операция уже видна как `running` через [`GET /v1/infra/servers/:id/operations`](./operations.md), без шагов выкладки. Если пробуждение вернуло отказ и запись операции удалось создать, ответ несёт тот же id в `X-Vibe-Operation-Id` и `error.operationId`; `503 WAKE_TIMEOUT` остаётся `503`, а после успешной асинхронной записи код операции — `WAKE_TIMEOUT`. Сразу после ответа операция может кратко оставаться `running`; при сбое терминальной записи она позднее читается как `unknown`. Клиент с коротким таймаутом закрывает соединение раньше и видит ответ `200`, тело которого пусто или не разбирается как JSON, а при обрыве во время пробуждения ответа нет вовсе. Выкладка при этом продолжается на сервере, и повторять её нельзя. Сколько платформа удерживает соединение и какой таймаут ставить — «Долгий деплой и таймауты HTTP-клиента» в разделе «Известные особенности». Пошаговый прогресс вместо ожидания даёт режим `?stream=true`, а исход прерванной выкладки читается по идентификатору операции — [Исход выкладки](./operation-status.md). При живой операции смены тарифа (`resize`) спящий сервер получает `409 EXEC_BUSY` до пробуждения; дождитесь её завершения. Не используйте ручное пробуждение для обхода resize.

У galaxy-приложения (`kind: "GALAXY_APP"`) ответ приходит целиком по завершении сборки — и заголовки, и тело, — поэтому запас по таймауту нужен такой же, а признака «работа идёт» до ответа нет.

При `success: false` есть дополнительно `error.code`, `error.message`, `error.step` с именем провалившегося шага, а `data.steps` содержит хронологию шагов до провала. Если сработал автооткат хардненного запуска, в конверте ошибки появляется ещё `error.hardeningRollback` — то же значение, что у шага `hardening`. Поле есть только у обычного ответа. При `?stream=true` конверт с ним не приходит: исход читается по кадру `event: step` со `status: "error"` и по предшествующему ему кадру шага `hardening`. Кадр `event: error` в потоке всё же бывает — при обрыве операции и когда сервер занят другой операцией, — но `hardeningRollback` он не несёт. **Отсутствие поля означает «откат не запускался», а НЕ «изоляция не применялась»** — на второй вопрос отвечает шаг `service_user` (`ok` — применялась, `warning` — нет, шага нет вовсе — деплой не дошёл до этого места). На отдельной виртуальной машине (`kind: "STANDALONE"`) такой ответ приходит с HTTP-статусом 200 — соединение удерживается с начала деплоя, поэтому провал шага виден по полю `success`, а не по статусу. У galaxy-приложения (`kind: "GALAXY_APP"`) та же ошибка приходит со статусом `502`.

**Форма ответа galaxy-приложения отличается.** У galaxy-приложения (`kind: "GALAXY_APP"`) успешный ответ несёт `data` вида `{ "id", "status", "appUrl", "buildLog", "steps", "autoSaved", "skippedReason" }`, плюс `warnings[]`, когда `displayName`/`description` не переданы либо не применены, а также при деплое с OAuth-app-ключом (см. выше). Поле `sleepAfterMinutes` в эту форму не входит — оно относится только к ответу деплоя отдельной виртуальной машины. Поля `autoSaved` и `skippedReason` лежат прямо в `data`, а не в `data.source`, а поля `data.source.savedVersionId` в ответе нет — итог автосохранения исходников читайте по `data.autoSaved`. Подробности сборки остаются в поле `buildLog`.

Массив `data.steps[]` у galaxy-приложения короче, чем у отдельной машины, и состоит максимум из двух шагов:

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

Шаг `build` со статусом `ok` есть в каждом успешном ответе. Шаг `healthcheck` добавляется, только когда платформа обращалась к приложению по HTTP: `status: "ok"` — приложение ответило кодом `2xx` или `3xx`, `status: "warning"` — ответило кодом `4xx` или `5xx`, и деплой всё равно считается успешным, потому что приложение доступно.

## Пример ответа

Успешный деплой (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.tech",
    "sleepAfterMinutes": 60,
    "source": { "autoSaved": false, "skippedReason": "deploy from external URL" }
  }
}
```

Ошибка получения `source.url` сохраняет верхнеуровневый код `DEPLOY_FAILED`, а стабильные детали дублируются в `error` и в терминальном шаге:

```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
      }
    ]
  }
}
```

Блок `data.source` — итог автосохранения исходников. В примерах выше деплой идёт с внешнего URL с заголовком `X-Skip-Source-Snapshot`, поэтому `autoSaved: false`, а `skippedReason` повторяет переданную причину. При деплое из хранилища — `source.content` или `source.versionId` — блок выглядит как `{ "autoSaved": true, "savedVersionId": "vN", "skippedReason": null }`. Полный контракт и значения `skippedReason` — [Хранилище исходников](/docs/source-storage).

Поле `warnings` в успешном ответе появляется, только если есть на что обратить внимание — например, `displayName`/`description` не переданы либо не применены (см. «Известные особенности») либо шаг `runtime` завершился со статусом `warning` и его сообщения ждут в `stdout` этого шага. В JSON-режиме это `warnings` на верхнем уровне ответа рядом с `data`. В SSE-режиме — то же поле в финальном `event: done`.

Успешный деплой обычной отдельной виртуальной машины, не защищённой политикой авто-сна для сервера агента или бота, добавляет в конец `warnings[]` одну запись с префиксом `AUTO-SLEEP POLICY:`, если снимок на момент начала деплоя содержит числовой `sleepAfterMinutes` и ни одного включённого повторяющегося окна пробуждения. Отключённые окна предупреждение не скрывают, а хотя бы одно включённое окно или `sleepAfterMinutes: null` скрывает. Это лишь условие показа deploy-warning: его отсутствие не означает, что сервер не уснёт. При применимом сне по расписанию числовой таймаут сохраняется, а при `null` используется платформенный таймаут — по умолчанию 15 минут. На невытесняемом тарифе с `null` и без назначенного рабочего расписания сервер не засыпает по простою даже при наличии окон пробуждения. Проверка и возвращённое значение относятся к одному стартовому снимку: изменение политики во время деплоя отразится только в следующем ответе. Предупреждение справочное и успешный деплой не отменяет.

Во время долгой подготовки runtime SSE-поток повторяет кадр `event: step` со статусом `running` и полем `elapsedSeconds`. Это число полных секунд с начала шага; поле отсутствует в первом кадре и во всех итоговых кадрах `ok`/`error`. Обычный JSON-ответ по-прежнему завершается только в конце операции и поддерживает соединение пробелами, поэтому для содержательного промежуточного прогресса используйте `?stream=true`.

SSE-поток при `?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.tech","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"}
```

## Пример ответа при ошибке

Деплой упал на шаге install (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" }
    ]
  }
}
```

Приложение поднялось, но проверка работоспособности отвечает ошибкой, и платформа успела вернуть
его на права администратора (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": "…" }
    ]
  }
}
```

Что здесь читает программа, не разбирая текст: `httpCode: 503` — приложение отвечает по HTTP, то
есть код выложен и процесс поднят. `hardeningRollback: "completed"` — изоляция снята, приложение
работает с правами администратора. Деплой при этом **не** засчитан: судит `success` и статус шага,
а не `httpCode`. `portOwner: "unknown"` здесь нормален — зонд владения портом на ответах 5xx не
запускается.

## Ошибки

| HTTP | Код | Описание |
|------|-----|----------|
| 400 | `VALIDATION_ERROR` | Нарушена схема запроса: отсутствует `source` или `start`, в multipart-режиме не передан файл архива, `port` вне диапазона 1–65535 или не передан в multipart-режиме, неверный формат архива |
| 400 | `NOT_BLACKHOLE` | Сервер в режиме OPEN — Deploy API недоступен |
| 401 | `MISSING_API_KEY` | Не передан заголовок `X-Api-Key` |
| 401 | `INVALID_API_KEY` | Ключ не опознан: такой строки на платформе нет |
| 402 | `ACCOUNT_FROZEN` | Баланс Вайбкод заморожен |
| 402 | `BILLING_EXHAUSTED` | Баланс исчерпан — пополните |
| 402 | `MARKETPLACE_REQUIRED` | На портале нет активной подписки, а доступ к платформе в его регионе открывает именно она. Новая версия уже созданного приложения отклоняется так же, как создание нового приложения: проверка идёт первой, до пробуждения сервера и до приёма архива, поэтому отказ ничего за собой не оставляет. Уже работающее приложение продолжает отвечать. Пути решения — в `error.alternatives`, ссылка на оформление — в `error.details.upgradeUrl`; блок `error.details.activation` говорит, откроет ли доступ пробный период Маркетплейса. Какой путь доступа действует в регионе портала — [Тариф и доступ](/docs/infra#тариф-и-доступ) |
| 402 | `BY_PAID_ONLY` | Доступ открывает платный или демо-тариф Битрикс24. Открывает его и платная подписка Битрикс24 Маркет Плюс — в Беларуси она продаётся. Без них новая версия не выкладывается, уже работающее приложение продолжает отвечать |
| 402 | `KZ_PAID_ONLY` | Регион портала открывает доступ по тарифу: платный или демо-тариф Битрикс24, подписка в регионе не продаётся. Без него новая версия не выкладывается, уже работающее приложение продолжает отвечать |
| 402 | `UZ_PAID_ONLY` | То же для региона UZ: платный или демо-тариф Битрикс24, подписка в регионе не продаётся |
| 403 | `PORTAL_SUBSCRIPTION_UNREADABLE` | Состояние подписки аккаунта прочитать не удалось: ключу разработчика не хватает прав на чтение лицензии. Покупка такой отказ не снимает, поэтому поле `error.details.requiredTariffs` пустое. Переподключите приложение под главным администратором аккаунта, чтобы ключ получил нужные права. Код `402 MARKETPLACE_REQUIRED` означает другое: подписка прочитана, и её нет |
| 403 | `PORTAL_TARIFF_UNREADABLE` | Тариф аккаунта прочитать не удалось, поэтому какого тарифа не хватает, ответ не называет: поле `error.details.requiredTariffs` пустое, а кнопка ведёт в поддержку. Покупка тарифа отказ не снимает |
| 403 | `INFRA_FORBIDDEN_FOR_COWORK_KEY` | Вызов сделан ключом Cowork/Code — такой ключ работает только с данными, изменяющие операции ему закрыты. Что делать — [Проектный ключ для деплоя](/docs/cowork/deploy-key) |
| 403 | `WRONG_KEY` | Сервер существует, но вызывающему не принадлежит. Операции над содержимым приложения — выкладка, команда, загрузка файла и чтение логов — открыты по любому из трёх оснований: управляющий ключ сервера, ключ, приложение которого привязано к этому серверу (`Application.serverId`), либо членство в команде разработки этого сервера (обе роли, «Разработчик» и «Администратор»). Токены доступа и загрузка значка требуют именно управляющего ключа. Управление самой машиной открыто ещё и роли «Администратор» команды. В ответе — `hint` с восстановлением из двух шагов. Подробнее — [Восстановление доступа к серверу](/docs/infra/server-access-recovery) |
| 404 | `NOT_FOUND` | Сервера с таким `id` нет или он удалён. Сервер, который существует, но вам не принадлежит, отвечает `403 WRONG_KEY` — код различает «нет такого сервера» и «нет прав на него» |
| 403 | `AGENT_SLOT_DEPLOY_FORBIDDEN` | Сервер принадлежит AI-агенту — выложить в него приложение нельзя, иначе код агента будет затёрт. Нажмите «Повторить» на карточке агента, чтобы развернуть его заново, либо создайте отдельный сервер под приложение |
| 403 | `SERVER_WAKE_BLOCKED` | Сервер спит, а пробуждение запрещено — например, закончился доступ к платформе или действует административная блокировка. Деплой такой сервер не поднимает, повтор не поможет, пока запрет не снят. Разбор запрета — [Разбудить сервер](/docs/infra/lifecycle/wake) |
| 403 | `EXTERNAL_COLLABORATOR_KEY_SERVER_MISMATCH` | Вызов сделан ключом внешнего участника разработки, а в адресе указан не тот сервер, для которого ключ выпущен. Такой ключ действует ровно на одном сервере, и ответ не сообщает, существует ли запрошенный сервер. Для работы с другим сервером нужен ключ того внешнего членства |
| 403 | `EXTERNAL_COLLABORATOR_NOT_A_MEMBER` | Ключ внешнего участника обращается к своему серверу, но доступа сейчас нет: членство снято, истёк его срок либо для аккаунта Битрикс24, которому принадлежит сервер, отключены внешние участники разработки. Код эти причины не различает и сведений о сервере не раскрывает. Членство перечитывается на каждом запросе, поэтому снятый доступ перестаёт работать сразу |
| 409 | `WAKE_IN_PROGRESS` | Спящий сервер уже будит другой запрос. Дождитесь его завершения и повторите деплой |
| 409 | `CONFLICT` | Состояние спящего сервера изменилось между проверкой доступа и пробуждением. Прочитайте текущее состояние сервера и исход операции, затем решите, нужен ли повторный деплой |
| 422 | `VM_MISSING` | У записи сервера нет виртуальной машины у облачного провайдера, поэтому будить нечего. Сервер нужно заменить: удалить и создать новый. Адрес каждого шага `error.hint` называет, только если шаг доступен этому запросу, а для недоступного — причину отказа и что изменить. Поле `error.userMessage` приходит, только когда доступны оба шага |
| 422 | `VM_POINTER_LOST` | У записи нет `externalId`, но разбор облака видит машину под облачным именем этого сервера: связь потеряна, сама машина скорее всего работает и тарифицируется. Удалять сервер и создавать новый НЕ нужно — тогда машина останется работать вообще без записи. Сервер в `error` не переводится, обратитесь в поддержку за восстановлением связи |
| 502 | `WAKE_FAILED` | Готовность сервера не подтверждена. Ответ не означает, что сервер остановлен. Перед следующим действием проверьте [состояние сервера](/docs/infra/servers/get) и [исход операции](./operations.md); не повторяйте выкладку вслепую |
| 502 | `PROVIDER_ERROR` | Облачный провайдер вернул ошибку при запуске виртуальной машины спящего сервера. Туннель здесь ни при чём — вызов [`/repair`](/docs/infra/lifecycle/repair) не поможет |
| 503 | `WAKE_NO_CAPACITY` | Запуск уже отклонён из-за нехватки мощности. Прочитайте актуальное [состояние сервера](/docs/infra/servers/get) перед следующей попыткой |
| 503 | `WAKE_TIMEOUT` | Спящий сервер не поднялся за отведённые 6.5 минуты — машина не запустилась или туннель не подключился. Сервер возвращается в `sleeping`, поэтому повторный запрос безопасен. Если запись операции удалось создать, ответ несёт её id в `X-Vibe-Operation-Id` и `error.operationId`; после успешной асинхронной записи исход операции — `failed` с кодом `WAKE_TIMEOUT`. См. «Известные особенности» |
| 409 | `EXEC_BUSY` | При живой операции смены тарифа (`resize`) спящий сервер получает `409 EXEC_BUSY` до пробуждения; дождитесь её завершения. Другая операция уже выполняется на сервере. Ответ несёт заголовок `Retry-After` и поля `retryable: true` / `retryAfter` (секунды) — повторяйте с этим интервалом. Не помогает — [`POST /v1/infra/servers/:id/unstick`](/docs/infra/servers/unstick), он отвечает `409 OPERATION_IN_PROGRESS`, пока замок наблюдается, а сам снимает только exec-мьютекс, переживший собственный TTL. Безусловное [`DELETE /lock`](./lock.md) — только после независимо подтверждённого простоя |
| 409 | `SERVER_NOT_READY` | Сервер не готов к операции: не запущен, туннель не подключён, либо сервер числится подключённым, но успешный непустой снимок Gateway подтверждает отсутствие живого туннеля. Пустой или недоступный снимок не считается таким подтверждением. В ответе — поле `hint` с причиной и следующим шагом. Платформа пытается восстановить подтверждённо отсутствующий туннель сама. Для остановленной машины сначала вызовите [`/start`](/docs/infra/lifecycle/start); для stale/DISCONNECTED-туннеля — [`/repair`](/docs/infra/lifecycle/repair), затем повторите запрос. Спящий сервер деплой будит сам. При живой операции смены тарифа (`resize`) спящий сервер получает `409 EXEC_BUSY` до пробуждения; дождитесь её завершения. |
| 409 | `AGENT_UPDATE_REQUIRED` | Standalone deploy с `extractTo`, равным `/srv` или вложенным в него, требует агент 1.3.22 или новее независимо от runtime и `systemd`. Galaxy игнорирует `extractTo` и не использует этот гейт. Standalone-запрос отклоняется до остановки сервиса и очистки каталога; обновите агент и повторите запрос |
| 409 | `SNAPSHOT_REQUIRED` | Деплой с внешнего `source.url` при включённом хранилище исходников. Передайте `X-Skip-Source-Snapshot: <причина>` или загрузите архив в хранилище и разверните через `{source: {versionId}}` — см. «Известные особенности» |
| 409 | `BASE_VERSION_REQUIRED` | В деплое нет `baseVersionId`, хотя хранилище исходников непустое и либо его последнюю версию сохранил другой разработчик, либо у сервера есть команда разработки. Если рабочая копия сделана с `latestVersionId`, повторите запрос с этой базой; иначе сначала скачайте актуальную версию и совместите изменения |
| 409 | `SOURCE_VERSION_STALE` | Указанный `baseVersionId` старше `latestVersionId`. Скачайте актуальную версию, повторно наложите на неё изменения и лишь затем отправьте деплой с этой базой. Слепая подстановка номера из ответа может затереть чужую работу |
| 400 | `INVALID_EXTRACT_TO` | Путь `extractTo` не подходит: он должен быть абсолютным, из букв, цифр, точки, подчёркивания, дефиса и косой черты, без завершающей косой черты, внутри одного из корней `/opt`, `/srv`, `/tmp`, `/root`, `/var/log`, `/var/lib`, `/etc/systemd`, `/etc/nginx` и не может указывать на каталоги ключей и расписаний (`/root/.ssh`, `/etc/cron*` и подобные). Для managed standalone static с `systemd: true` правило уже: вложенный прикладной каталог под `/opt` или `/srv`; все пути под `/var/lib` и `/opt/data` отвергаются. Для остальных рантаймов и static с `systemd: false` действует общее правило. Текст ответа называет нарушенное правило. Проверка идёт до того, как деплой что-либо сделает с сервером. На galaxy-приложении поле игнорируется, но проверяется |
| 400 | `INVALID_DATA_DIRS` | Каталог из `dataDirs` не подходит: каждый путь должен быть абсолютным, уже нормализованным, внутри `/opt`, `/srv` или `/var/lib` и иметь собственное имя каталога — сами корни (`/opt`, `/var/lib`) отвергаются. Этот же код приходит, если `dataDirsRecursive` передан без `dataDirs` или запрошен для `/opt/data`. Текст ответа называет конкретный путь и что прислать вместо него. Проверка идёт до того, как деплой займёт сервер |
| 400 | `GALAXY_DEPLOY_RUNTIME_REQUIRED` | Деплой galaxy-приложения без `runtime`. Укажите рантайм, например `node24` |
| 400 | `GALAXY_DEPLOY_CONTENT_ONLY` | Деплой galaxy-приложения с `source.url` или `source.versionId` там, где выкладка по ссылке для вас не включена. Пришлите архив встроенным `source.content` |
| 400 | `GALAXY_SOURCE_URL_NOT_ALLOWED` | Ссылка в `source.url` не прошла проверку до отправки на хост галактики. Причины: адрес ведёт не в [хранилище исходников](/docs/source-storage) платформы, значение не является абсолютным URL, схема не `http` и не `https`, адрес ведёт не в публичную сеть — на локальный, внутренний или служебный узел. Текст ответа называет сработавшее правило, а для стороннего адреса подсказывает замену — сохранить архив версией и выложить по `source.versionId`. Та же проверка стоит на [создании сервера](/docs/infra/servers/create) с полем `source`. Не путайте с `GALAXY_DEPLOY_CONTENT_ONLY` — там ссылка отклоняется целиком как способ выкладки, здесь не подходит конкретный адрес |
| 400 | `SOURCE_VERSION_REQUIRES_APP` | Деплой по `source.versionId` на сервер, которым владеет другой ключ — менеджмент-ключ или доступ через карточку приложения. Версия ищется в контексте приложения, а ключ-владелец сервера к приложению не привязан. Разверните версию тем ключом, которому принадлежит сервер |
| 404 | `SOURCE_VERSION_NOT_FOUND` | Только galaxy-приложение: версии `source.versionId` нет в депо этого приложения |
| 404 | `SOURCE_VERSION_NOT_FOUND` | Только отдельная машина: версии нет ни в депо самого сервера, ни в депо приложения-владельца. Когда сервер принадлежит вызывающему ключу, текст ответа предлагает сначала сохранить архив через `POST /v1/infra/servers/:id/sources` |
| 410 | `SOURCE_VERSION_BYTES_PURGED` | Запись о версии жива, но её байты вычищены из хранилища. Сохраните архив заново — восстановить прежние байты нельзя. Подробнее — [Хранилище исходников](/docs/source-storage) |
| 400 | `GALAXY_HOST_NOT_A_DEPLOY_TARGET` | Деплой пришёл на id машины-галактики (`kind: "GALAXY"`). Машина несёт контейнеры приложений и целью выкладки не является — обращайтесь по id самого приложения (`kind: "GALAXY_APP"`), см. [Galaxy-приложение](../galaxy.md) |
| 400 | `GALAXY_DEPLOY_INVALID_INSTALL` | Деплой galaxy-приложения с многострочной командой `install`. Для galaxy-приложения `install` — только однострочная команда, перевод строки ломает сборку |
| 409 | `GALAXY_BUILD_BUSY` | На хосте galaxy сейчас собирается другое приложение. Повторите запрос через несколько секунд |
| 409 | `GALAXY_APP_BUSY` | На этом galaxy-приложении уже выполняется другая операция. Повторите запрос через несколько секунд |
| 413 | `GALAXY_UPLOAD_TOO_LARGE` | Архив исходников превысил лимит загрузки шлюза (до начала сборки). Ошибка детерминированная — повтор того же архива не поможет. Уменьшите архив (исключите `node_modules`, `.git` и артефакты сборки — зависимости ставятся на хосте). Если выкладка по ссылке для вас включена, тот же архив можно прислать в `source.url` — его скачивает хост, и этот потолок на него не распространяется. Иначе уменьшить архив — единственный способ восстановления |
| 413 | `INLINE_SOURCE_TOO_LARGE` | Тело со встроенным архивом больше 96 МБ, то есть около 72 МБ самого архива. Решение принимается по заголовку `Content-Length` до чтения тела, поэтому отказ детерминированный — то же тело повторной отправкой не пройдёт. Трафик это не экономит: платформа принимает тело целиком, и отказ приходит после того, как загрузка закончилась. В `error.hint` приходят четыре строки — `reason`, `recovery`, `recoveryAction` и `note` — с готовым переходом на версионный путь. Тот же код приходит в multipart-режиме, когда архив не удалось записать в хранилище потоком и он оказался крупнее 72 МБ, — условия в «Известных особенностях» |
| 413 | `PAYLOAD_TOO_LARGE` | Тело запроса превысило краевой лимит платформы — 500 МБ — и отклонено ещё до обработчика. На этом маршруте в краевой лимит упирается multipart-режим: у встроенного base64-архива потолок ниже, и он приходит кодом `INLINE_SOURCE_TOO_LARGE`. Тот же краевой лимит стоит на остальных маршрутах `/v1/`, а маршруты AI отдают ошибку в конверте, совместимом с OpenAI. Отличается от `GALAXY_UPLOAD_TOO_LARGE`, который приходит от шлюза уже во время galaxy-деплоя |
| 502 | `GALAXY_BASE_IMAGE_UNAVAILABLE` | Сборка не смогла получить базовый образ — склад образов был недоступен с хоста галактики. Состояние временное: ответ несёт `retryable: true` и `error.hint` из четырёх строк — `reason`, `recovery`, `recoveryAction`, `note`. Слот приложения сломанным не помечается — в том числе при создании сервера сразу с полем `source` (HTTP-ответ деплоя тогда уже ушёл, причина остаётся в `provisionError`). Повторяйте тот же деплой с интервалом в несколько минут: окно недоступности обычно короткое, но бывает и долгим. Удалять и пересоздавать приложение не нужно — новый слот попадёт на тот же хост |
| 502 | `GALAXY_LOW_DISK` | На хосте галактики кончилось свободное место во время сборки. Состояние повторяемое: ответ несёт `retryable: true` и `error.hint` из четырёх строк — `reason`, `recovery`, `recoveryAction`, `note`. Слот приложения, его контейнер и том `/data` целы, приложение сломанным не помечается, а уже работающая версия продолжает обслуживать запросы. Повторять деплой имеет смысл после того, как на хосте освободили место: само оно не освобождается, поэтому немедленный повтор упирается в тот же отказ. При создании сервера сразу с полем `source` приложение сломанным тоже не помечается — причина приходит на карточке сервера в `provisionError` с категорией `RESOURCE` |
| 409 | `GALAXY_UPLOAD_BUSY` | Другая загрузка использует путь исходников. Повторите тот же деплой не ранее чем через 10 секунд; тело несёт `retryable: true`, `retryAfter: 10` и `hint`, заголовок — `Retry-After: 10`. Статус приложения сохраняется |
| 502 | `GALAXY_APP_BUILD_FAILED` | Сборка контейнера galaxy-приложения не удалась. Хвост лога сборки — в поле `buildLog`. Когда причину удалось распознать, ответ дополнительно несёт `error.category` и `error.buildHint` — см. «Известные особенности» |
| 502 | `GALAXY_APP_START_FAILED` | Контейнер galaxy-приложения собрался, но упал или ушёл в перезапуск по нехватке памяти сразу после старта. Хвост лога — в поле `buildLog`. Поля `error.category` и `error.buildHint` приходят по тем же правилам, что и у `GALAXY_APP_BUILD_FAILED` |
| 502 | `GALAXY_DEPLOY_INTERRUPTED` | Деплой не удалось подтвердить. Если устойчивый статус операции — `unknown`, ответ несёт `retryable: false`: сначала прочитайте список операций, состояние сервера и логи, не повторяйте deploy вслепую. Только доказанно провалившаяся операция, чья подсказка разрешает тот же запрос, несёт `retryable: true`. Поле `error.subcause` называет форму обрыва: `http_window_exhausted`, `exec_channel_busy`, `no_this_deploy_container`, `tail_unreached` или `source_fetch_interrupted`. Поле `error.repeated` становится `true`, когда одно приложение прерывалось несколько раз за короткое окно: прекратите цикл deploy, проверьте запуск приложения (команда запуска, порт, зависимости, переменные окружения, лимит памяти), а для заведомо рабочего приложения обратитесь в поддержку с идентификатором сервера. Слот, контейнер и том `/data` сохранены при любом из этих исходов; не удаляйте и не пересоздавайте приложение |
| 502 | `GALAXY_HOST_UNREACHABLE` | Хост galaxy недоступен — туннель хоста не в статусе `CONNECTED`. Если ответ несёт `retryable: false`, deploy уже был отправлен, но его устойчивый исход — `unknown`: сначала прочитайте список операций, состояние сервера и логи, не повторяйте deploy вслепую. Без `retryable: false` отказ произошёл до отправки сборки — следуйте `error.hint`, который разрешит повтор после восстановления туннеля. Приложение и его данные сохранены |
| 502 | `GALAXY_APP_DEPLOY_FAILED` | Не удалось развернуть galaxy-приложение — непредвиденный сбой деплоя. Повторите запрос |
| 502 | `SOURCE_DEPOT_UNAVAILABLE` | Только multipart-режим: хранилище исходников не приняло архив, деплой не стартовал. Тело запроса корректно — повторите тот же запрос без изменений. Раньше такой сбой приходил как `400 VALIDATION_ERROR`, поэтому клиент, который ветвится по статусу, теперь попадёт в другую ветку |
| 429 | `RATE_LIMITED` | Превышен лимит 10 операций в минуту на сервер. Действующее значение — в заголовке `x-ratelimit-limit` |
| 429 | `DEPLOY_BACKEND_BUSY` | Слишком много одновременных запросов со встроенным телом: счётчик общий у `/deploy`, [`/upload`](/docs/infra/deploy/upload) и [создания сервера](/docs/infra/servers/create) с полем `source.content`. Ответ содержит заголовок `Retry-After: 30` — повторите через указанное время либо передайте архив ссылкой в `source.url`: ссылка не занимает inline-memory слот, но платформенный relay имеет отдельный ограниченный пул |
| 200 | `DEPLOY_FAILED` | Упал один из шагов процесса деплоя. Поле `error.step` указывает, какой. Для `download` поля `error.causeCode`, `error.retryable` и, когда известно, `error.attempts` дублируют стабильные значения терминального шага. Ветвитесь по ним, а не по тексту. `DOWNLOAD_RELAY_BUSY` возникает после успешно завершившегося `runtime`, если он был передан, но до `stop_existing` и `clean`: оркестратор ещё не останавливал юнит и не очищал дерево приложения, однако установщик рантайма уже мог изменить системные зависимости хоста. Эти изменения не откатываются. `DOWNLOAD_URL_DENIED`, `DOWNLOAD_TOO_LARGE`, `DOWNLOAD_INTEGRITY_MISMATCH` и `DOWNLOAD_EXTRACT_FAILED` повторять без исправления причины не нужно. `DOWNLOAD_COMMIT_UNCONFIRMED` и `DOWNLOAD_OUTCOME_UNCONFIRMED` означают устойчивый исход операции `unknown`, всегда несут `retryable: false` и требуют сначала прочитать список операций. Если сработал автооткат хардненного запуска, конверт несёт ещё `error.hardeningRollback` |
| 200 | `DEPLOY_TIMEOUT` | Шлюз перестал ждать шаг деплоя, но это не доказывает остановку команды на сервере. Устойчивый исход операции — `unknown`: сначала выполните действие из `error.hint` и прочитайте [список операций](./operations.md), не повторяйте deploy вслепую |
| 200 | `DEPLOY_FAILED` с причиной `GATEWAY_UNREACHABLE` | Платформа потеряла связь со шлюзом и не знает исход операции. `error.hint` запрещает слепой повтор и направляет сначала в [список операций](./operations.md); `unstick` допустим после подтверждённого `EXEC_BUSY`: он гардирован и на живой операции без `force` отвечает `409`. Независимое подтверждение простоя — состоянием сервера и логами — нужно ОБОИМ шагам: и `/unstick`, чей разрыв туннеля убивает всё, что выполняет агент, и тем более безусловному `DELETE /lock` после него. Список операций этого не доказывает: он показывает лишь запуски вашего ключа. Отсутствие `error.hint.holder` тоже не доказательство: чтение держателя fail-open, поэтому «держателя нет» приходит и когда прочитать его не удалось |
| 200 | `DEPLOY_CONNECTION_TERMINATED` | Соединение с сервером оборвалось посреди деплоя — агент или туннель отключились. Устойчивый исход операции — `unknown`: прочитайте [список операций](./operations.md), состояние сервера и логи по `error.hint`, не повторяйте deploy вслепую |
| 200 | `DEPLOY_TUNNEL_STALE` | У Gateway нет живого туннеля для сервера, хотя сервер числится подключённым. Обрыв не доказывает исход уже отправленного deploy: сначала прочитайте [список операций](./operations.md) и выполните read-only проверки из `error.hint`, не вызывайте `/repair` и не повторяйте deploy вслепую |
| 502 | `RUNTIME_FAILED` | Установка рантайма на сервере провалилась. Попробуйте без `runtime` или через отдельный [`/exec`](./exec.md) |
| 503 | `RUNTIME_TIMEOUT` | Установка рантайма не завершилась за 10 минут. Она может завершиться позже — проверьте статус сервера перед повторным запросом. Тело ответа содержит поле `retryAfter` со значением 30, ответ несёт заголовок `Retry-After: 30` — это число секунд до повторного запроса |

> **HTTP-статус не отражает результат деплоя.** На отдельной виртуальной машине (`kind: "STANDALONE"`) соединение удерживается на всё время деплоя, а статус `200` отправляется до его начала — поэтому и успешный, и провалившийся деплой приходят с кодом `200`. Признак отказа — поле `success: false` и объект `error` в теле ответа. **Проверяйте `success` в теле, а не HTTP-статус:** клиент, который ветвится по статусу, посчитает провалившийся деплой успешным. У galaxy-приложения (`kind: "GALAXY_APP"`) та же ошибка приходит со статусом `502`.
>
> В потоковом режиме (`?stream=true`) провал шага приходит SSE-событием `step` со `status: "error"` — оно несёт имя провалившегося шага. При исключении или обрыве транспорта отдельное событие `error` несёт `code` и `message`. При зависшем exec-мьютексе завершающее событие `error` несёт `code: "DEPLOY_FAILED"`, `step` и `hint`, но без `message`. Поля `success` в потоке нет — признак отказа шага — это `status: "error"` в его событии `step`.

Полный список общих ошибок API — [Ошибки](/docs/errors).

## Платформа сообщает порт в `PORT`

Приложение не обязано знать номер порта заранее: платформа сама передаёт его в переменной
окружения `PORT` — по той же общей конвенции, что Heroku, Render, Railway и Cloud Run. Значение
всегда равно полю `port` запроса (по умолчанию `3000`) — тому же номеру, на который платформа
пробрасывает публичный трафик и по которому проверяет работоспособность.

Поэтому рабочий код — обычный:

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

Как это доставляется:

- **Отдельная виртуальная машина** (`kind: "STANDALONE"`) — шаг `platform_env` пишет рядом с вашим
  `.env` отдельный файл `.vibe-platform.env` с единственной строкой `PORT`, а systemd-юнит
  подключает оба файла: сначала ваш, потом платформенный. Systemd применяет их в порядке
  объявления, поэтому для одноимённой переменной побеждает последний — платформенный. Ваш `.env`
  при этом не читается и не перезаписывается.
- **Приложение в галактике** (`kind: "GALAXY_APP"`) — `PORT` передаётся контейнеру как переменная
  окружения при запуске, рядом с вашими переменными.

Что стоит учесть:

- **`PORT` зарезервирован платформой.** Если прислать свой `env.PORT`, отличный от поля `port`,
  платформа его перекроет и скажет об этом строкой в `warnings[]` ответа — вида «Deploy field
  "port" is 3000; your env.PORT was "9999" and was overridden». Это не ошибка, деплой проходит.
  Прислать совпадающее значение можно — предупреждения не будет.
- **Если вы держали в `env.PORT` не порт приложения**, а что-то другое — порт базы, порт внешнего
  сервиса, — переименуйте эту переменную. Раньше ваше значение доходило до приложения, теперь
  `PORT` принадлежит платформе и будет перекрыт. Предупреждение в `warnings[]` как раз про этот
  случай.
- **Приложение, которое читает файл `.env` напрямую** (а не переменные окружения процесса),
  `PORT` в нём не найдёт: платформенное значение живёт в отдельном файле. Читайте
  `process.env.PORT` — популярные загрузчики (`dotenv` и аналоги) по умолчанию не перетирают уже
  заданные переменные окружения, поэтому такой код работает без правок.
- **Свой systemd-юнит (`systemd: false`).** Юнит в этом режиме создаёте вы, поэтому платформенный
  файл сам собой не подключится — добавьте в него строку
  `EnvironmentFile=-<extractTo>/.vibe-platform.env` **после** строки с вашим `.env`. Пока не
  добавили, побеждает ваш `env.PORT`, и предупреждение в ответе говорит именно это, а не «значение
  перекрыто». Файл платформа всё равно пишет, но если записать его не удалось, деплой в этом режиме
  не прерывается — шаг `platform_env` возвращает `warning`.
- **Уже работающие приложения** получат `PORT` при следующем деплое — задним числом окружение
  запущенного процесса не меняется.

## Известные особенности

- **Потолок multipart-архива зависит от того, ушёл ли архив в хранилище потоком.** Прежние 500 МБ действуют, только когда выполнены три условия сразу. Первое — вы обращаетесь к серверу как его прямой владелец, а не через карточку чужого приложения и не менеджмент-ключом. Второе — сервер либо не galaxy-приложение, либо выкладка galaxy-приложения по ссылке включена для вас полностью. Третье — хранилище исходников включено и платформой, и для вашего портала. Если не выполнено хотя бы одно, потоковая запись не включается, архив читается в память целиком, и предел для него становится тем же, что у встроенного base64-тела: около 72 МБ архива, отказ — `413 INLINE_SOURCE_TOO_LARGE` с тем же `error.hint`. Путь, который не зависит от этих условий, — сохранить архив версией через `POST /v1/infra/servers/:id/sources` и развернуть его по `{"source": {"versionId": "vN"}}`: там байты едут потоком и упираются в потолок версии, 500 МБ архива. Разбор хранилища — [Хранилище исходников](/docs/source-storage).
- **В multipart-режиме версия исходников заводится до старта деплоя.** Архив уходит в хранилище потоком, ещё пока запрос читается, и версия сразу получает статус `failed` — успешный деплой переключает её на `success`. Поэтому у неудавшейся выкладки версия остаётся в истории со статусом `failed`: это не мусор, а честная отметка «байты приняты, деплой не дошёл». Состав истории — [Хранилище исходников](/docs/source-storage). Если само хранилище архив не приняло, деплой не стартует вовсе и запрос возвращает `502 SOURCE_DEPOT_UNAVAILABLE` — тело корректно, помогает простой повтор.
- **Имя и описание карточки каталога Битрикс24 деплой ЗАПОЛНЯЕТ, но не переименовывает.** `displayName` записывается, только пока он ещё равен техническому идентификатору сервера, `description` — только пока оно пусто. Передавайте оба поля при первом деплое: без них в каталоге останется технический идентификатор, а ответ вернёт `warnings[]` с подсказкой. Если поле уже заполнено, присланное значение **не применяется** — ответ сообщит об этом отдельной записью в `warnings[]` и укажет на [`PATCH /v1/infra/servers/:id`](/docs/infra/servers/update), единственную ручку переименования. Отдельная запись приходит, когда ПРИМЕНЁННОЕ значение потеряло не-ASCII символы по дороге — так ведёт себя Windows PowerShell без явной сериализации в UTF-8: деплой при этом штатный, а строка уезжает в карточку как есть. Про отброшенное поле такой записи не будет — на карточку оно не поехало. Повторный деплой с тем же значением тоже молчит: записи не происходит, а чинить кодировку строки, которая уже лежит в карточке, нужно через переименование.
- **Кириллица в `displayName` и `description` из Windows PowerShell.** Отправленная без явной сериализации в UTF-8, она попадает в карточку каталога знаками вопроса (`?`): байты теряются на стороне клиента, до отправки запроса. Готовый вызов с `UTF8.GetBytes` — [Windows / PowerShell и UTF-8](/docs/infra#windows-powershell-и-utf-8).
- **Не-корневой `healthPath` означает, что деплой проверит и корень.** После того как заданный путь ответил, платформа опрашивает `/` — тот адрес, который открывает Битрикс24. Ответ 5xx на корне завершает деплой ошибкой на шаге `healthcheck`: лёгкая ручка вроде `/api/health` отвечает 200 даже тогда, когда страница приложения упала, и такой деплой раньше рапортовал об успехе. Ответ 4xx деплой не прерывает — приходит шаг `app_root` со статусом `warning`: сервер без страницы на корне (например, только с API) — рабочая форма. Не дошедшая проверка тоже даёт `warning`, а не ошибку. При `healthPath` по умолчанию (`/`) дополнительного запроса нет.
- **Не-корневой `healthPath` при корневом `appUrl` даёт `warnings[]`.** Битрикс24 открывает приложение по адресу `appUrl` связанного приложения, а платформа заполняет его голым origin поддомена — без пути. Если приложение отвечает ТОЛЬКО на подпуть, проверка работоспособности зелёная (она пробила именно подпуть), а фрейм Битрикс24 открывает корень, где приложения нет. Ответ деплоя содержит `warnings[]` с подсказкой — действуйте по ней **только если корень действительно не обслуживается**: пробник на подпути при UI в корне (`healthPath: "/health"`) — нормальная, правильная форма, и перенос такого пути в `appUrl` наоборот сломает встройку (фрейм откроет JSON-эндпоинт). Если подпуть действительно неизбежен, поправьте обе половины: деплойте туда И перенесите тот же подпуть в `appUrl` через `PATCH /v1/apps/{id}` (id — из `GET /v1/apps`, сопоставьте по `appUrl`). Проверка живёт только на `POST /v1/infra/servers/:id/deploy` и только когда `appUrl` указывает на поддомен ЭТОГО сервера. Ответы создания сервера её не несут. **Почему корень предпочтительнее, а не просто аккуратнее:** после входа зритель возвращается в корень поддомена, а не на подпуть, поэтому приложение с подпутём в `appUrl` на первом заходе за сессию приземляется на собственный 404. Приложение с роутером на «решётке» вдобавок теряет её на экране пробуждения. Это известное ограничение шлюза, оно чинится отдельно.
- **Статус `warning` на шаге `tunnel_routing` не означает провал деплоя.** После успешного `healthcheck` платформа проверяет, что туннель Gateway маршрутизирует публичный трафик на нужный порт (`set_port` + опрос агента). Если порт ещё не определён или агент работает в режиме фиксированного порта, шаг получает статус `warning` (не `error`) и деплой не прерывается. Публичный URL приложения может около минуты отдавать страницу-заглушку Black Hole, пока сканер портов агента не определит рабочий порт. Заглушка пропадёт сама, **если прежний порт освободился**. Если на прежнем порту остался живой отвечающий процесс, сканер намеренно удерживает его и сам не переключится — задайте порт явно через [`PATCH /port`](/docs/infra/deploy/port) или остановите тот процесс. Если у агента вообще нет автоопределения порта (`agentError: NO_SCANNER` в тексте шага) — вызовите [`/repair`](/docs/infra/lifecycle/repair).
- **Код приложения передаётся через `source.content`, а не через `start`/`install`/`preStart`.** Поля-команды (`start`, `install`, `preStart`) — это короткие shell-команды с лимитом 5000 символов каждое (`command` у [`/exec`](./exec.md) — 10000). Они не предназначены для содержимого файлов: base64 целого архива туда не помещается и обрезается. Сам код (tar.gz / zip, в том числе один файл) кладите в `source.content` — base64-строка архива, до 96 МБ на тело запроса, то есть около 72 МБ архива, — либо в `source.url`, а в `start` оставляйте только команду запуска (например `node server.js`). Архив крупнее 72 МБ во встроенном виде не проходит и возвращает `413 INLINE_SOURCE_TOO_LARGE`: отправляйте его ссылкой в `source.url` или сохранённой версией в `source.versionId`, оба пути принимают до 500 МБ архива. На galaxy-приложении оба пути доступны там, где для вас включена выкладка по ссылке, — иначе остаётся уменьшить архив.
- **Четыре стратегии для `.env`.** Файл пишется в `{extractTo}/.env` (по умолчанию `/opt/app/.env`) и при `cleanDeploy: true` удаляется перед следующим деплоем:
  1. Передавать `env` в каждом `/deploy` — перегенерируется автоматически (рекомендуется).
  2. `preserveEnv: true` — платформа читает `.env` до очистки и записывает его обратно на шаге `env`. Если деплой обрывается после создания снимка и перехода к очистке, например на загрузке архива или установке зависимостей, сохранённая копия возвращается на диск по мере возможности. Ошибка `runtime` происходит раньше снимка и очистки: платформа не читает прежний `.env` и не загружает копию обратно, а файл остаётся на месте. Подходит, когда переменных нет под рукой в момент повторного деплоя. Переданный в этом же запросе `env` побеждает: файл пишется заново, сохранённая копия отбрасывается. Только для отдельной виртуальной машины (`kind: "STANDALONE"`).
  3. Хранить `.env` вне `extractTo` — например, `/var/lib/myapp/.env`. Эта директория не трогается `cleanDeploy`. В `start` указать явный путь.
  4. `cleanDeploy: false` — накопительный (merge) деплой, `.env` переживёт, но вместе с ним останутся устаревшие файлы прошлых деплоев.

  Стратегии выше относятся к отдельной виртуальной машине. Поведение `env` у galaxy-приложения — в отдельной особенности ниже.
- **Часовой пояс окон пробуждения платформа дописывает в `.env` сама.** Если у отдельной виртуальной машины (`kind: "STANDALONE"`) есть включённые окна [пробуждения по расписанию](/docs/infra/wake-schedules) и все они объявлены в одном часовом поясе, деплой добавляет в `.env` переменную `TZ` с этим поясом — приложение получает её вместе с остальными переменными и считает местное время по ней. Присланный в поле `env` собственный `TZ` побеждает: платформа его не перекрывает. При `preserveEnv: true` пояс дописывается в восстановленный файл на шаге `env`, когда строки `TZ` в нём не было. Если деплой обрывается после создания снимка и перехода к очистке, но до шага `env`, сохранённая копия возвращается на диск как есть, без подстановки пояса — его добавит следующий успешный деплой. Ошибка `runtime` происходит раньше: прежний файл остаётся на месте без чтения и обратной загрузки. Если окна объявлены в разных поясах или окон нет, `TZ` не проставляется. У galaxy-приложения (`kind: "GALAXY_APP"`) пояс не подставляется — контейнер получает только переменные из поля `env` этого запроса.
- **Окно пробуждения само по себе не задаёт непрерывную работу.** Если [пробуждение по расписанию](/docs/infra/wake-schedules) доступно вашему порталу, включённое повторяющееся окно подходит для работы в заданное время. Если сон по простою применим, задача без входящей активности должна успеть завершиться раньше фактического таймаута простоя. Числовой таймаут расписание не сокращает. Когда сон по расписанию применим, при `null` действует платформенный таймаут — по умолчанию 15 минут; это возможно и у новых записей. На отдельной виртуальной машине с невытесняемым тарифом и без назначенного рабочего расписания `null` означает отсутствие сна по простою даже при существующих окнах пробуждения. Для такого сервера создание и изменение окна запрещены, в том числе отключённого; установка `null` запрещена при включённых окнах MANUAL/FILE. В режиме `SCHEDULE` эти проверки конфликта не действуют, но `PATCH /sleep` снимает рабочее расписание; ручные окна сохраняются. Чтобы отключить сон по простою, выберите невытесняемый тариф и передайте `{ "sleepAfterMinutes": null }`. Входящие HTTP-запросы и активность по SSH сбрасывают таймер, а cron, фоновые процессы и исходящий трафик приложения — нет. Отсутствие deploy-warning при `null` или уже созданных окнах само по себе не обещает режим без сна.
- **Приложению, которое само зовёт V1 API Вайбкод, нужен собственный ключ.** Если задеплоенное приложение обращается к V1 API Вайбкод (`/v1/deals`, `/v1/users` и т. п.), оно шлёт в заголовке `X-Api-Key` свой личный ключ `vibe_api_`. Платформа такой ключ не выдаёт автоматически: пользователь создаёт его в разделе [«API-ключи»](/docs/keys-auth) (`/keys`, для read-only дашборда — в режиме READONLY с минимальными правами) и передаёт приложению через параметр `env` (например `env: { "VIBE_API_KEY": "<ключ>" }`). Приложение читает `process.env.VIBE_API_KEY` и шлёт как `X-Api-Key`. Ответ `401` (`MISSING_API_KEY` / `INVALID_API_KEY`) означает, что приложение не несёт корректного ключа, а не проблему тарифа. Личный ключ обращается к Битрикс24 от имени своего владельца — держите `accessPolicy` сервера в `OWNER_ONLY`, иначе другие пользователи увидят данные владельца ключа. Если приложение встроено в Битрикс24 как placement и показывает данные per-user — это другой поток авторизации, см. [Авторизация в приложении](/docs/infra/app-runtime).
- **Автоматическая подгрузка `.env` в systemd.** В юните прописан `EnvironmentFile=-{extractTo}/.env` (знак `-` делает файл опциональным). Приложение через systemd получит переменные само. Но при `systemd: false` или командах из `install`/`preStart` env подгружать не будет — передавайте явно.
- **Если смешаны `dependencies` и `devDependencies`**, `npm install --production` не поставит пакеты из `devDependencies`. Для cron-скриптов с `tsx`, `ts-node` и т. п. уберите `--production` или добавьте их отдельно через `preStart`.
- **Откуда `pip` берёт пакеты.** Платформа задаёт каталог пакетов по умолчанию — зеркало, доступное из нашего облака (сам `pypi.org` оттуда не отвечает: установка зависала бы и сообщала «нет подходящей версии» на существующей версии). Настройка машинная, поэтому её видит и авто-установка `requirements.txt`, и ваша команда в поле `install`, и `pip`, запущенный вручную через `exec`. **Ваш выбор сильнее нашего:** `--index-url` в `requirements.txt` или в команде установки перебивает значение по умолчанию. Переменная `PIP_INDEX_URL` из поля `env` на сборку в галактике НЕ влияет — окружение приезжает в контейнер при запуске, а зависимости ставятся раньше, на сборке образа; на отдельной виртуальной машине она работает. Если вам важна неизменность пакетов — используйте `--require-hashes` с зафиксированными хешами: это единственный механизм, который защищает от подмены на стороне каталога. Менеджеры `poetry` и `uv` нашу настройку не читают — задавайте им индекс их собственными средствами.
- **Откуда npm и apt берут пакеты в Galaxy.** Перед сборкой платформа задаёт машинные конфигурации npm и Debian, поэтому их видят авто-установка и команда `install`; `env` деплоя на эти шаги не влияет. Свой registry в команде npm или свои Debian sources сильнее значения по умолчанию.
- **Повторный деплой с тем же `runtime` переустанавливает его.** Опустите параметр `runtime` при повторных деплоях — сэкономит 45+ секунд. Первый деплой с `runtime` обязателен для установки.
- **Проверка работоспособности может не успеть.** До 10 попыток с интервалом 3 секунды — это около 30 секунд на всё. Если приложение стартует дольше, сделайте эндпоинт `/health`, отвечающий 200 сразу, даже до прогрева, или переместите долгую инициализацию в `preStart`.
- **Команда `start` остаётся на переднем плане и сама держит слушателя.** На отдельной виртуальной машине платформа запускает её systemd-юнитом типа `simple`: живым юнит считается ровно до тех пор, пока не завершился запущенный процесс. Команда, которая передаёт работу фоновому демону и выходит сама, юнит не удерживает — после её выхода сервис переходит в состояние `inactive`, а оставшиеся от неё процессы systemd останавливает. Слушатель исчезает вместе с ними. Пишите команду, которая не возвращает управление: `node server.js`, `python3 app.py`, `nginx -g 'daemon off;'`. Какая команда Python нужна вашему рантайму, сказано в [Списке рантаймов](./runtimes.md). Менеджер процессов внутри `start` (`pm2 start server.js --name app && pm2 save` и любая другая форма запуска с последующим выходом) на этой схеме не работает, даже когда сам менеджер установлен рантаймом.

  Исходов у такой команды два, и оба от одной причины. Первый — проверка работоспособности не находит слушателя, и деплой завершается ошибкой `attempt 10/10: curl exit=7`. Второй — проверка успевает обратиться к приложению, пока фоновый процесс ещё жив, и тогда деплой заканчивается успехом, а приложение перестаёт отвечать через несколько секунд после него. Во втором случае ответ деплоя зелёный, а приложения на порту уже нет. Различает эти два исхода сам результат деплоя: красный со строкой `attempt 10/10: curl exit=7` — это первый, зелёный с замолчавшим через несколько секунд приложением — второй. Поле `portOwner` шага `healthcheck` исходы НЕ различает: `unknown` приходит в обоих — в первом зонд владения портом не запускался вовсе (кода в диапазоне 200–399 не было), во втором он отработал и не смог назвать владельцем ваш юнит. Значение `service` приходит только у команды, которая сама держит порт. Для команды `start`, запускающей процесс напрямую, `unknown` на зелёном деплое чаще всего означает, что порт держит оставленный фоновый процесс, а не юнит, но доказательством этого не является: тот же `unknown` приходит, когда зонд владения не смог отработать — его вызов оборвался по таймауту или на транспорте, — и тогда порт держит юнит, а с приложением всё в порядке. Различает эти случаи само приложение: то, которое отвечает и через минуту после деплоя, оставленным процессом не объясняется. Для приложения из Docker Compose этот вывод не делайте: там зелёный деплой с `unknown` бывает и без всякого оставленного процесса — зонд владения не запускался, потому что на последней попытке контейнер проекта ещё не был признан устоявшимся. Полный перечень причин `unknown` — в таблице полей ответа.

- **Успешный деплой означает, что приложение ответило на GET.** Шаги `healthcheck` и `app_root` запрашивают `healthPath` и корень методом GET. Готовность маршрутов POST, PATCH и DELETE деплой не проверяет: приложение, отвечающее на них ошибкой, проходит деплой зелёным. Проверяйте такие маршруты собственным вызовом после деплоя.

- **Фоновые приложения тоже должны слушать порт 3000.** Шаг `healthcheck` выполняется при каждом деплое — даже если приложение не обслуживает HTTP-трафик (бот, который только опрашивает события, cron-демон, обработчик очереди). Без процесса, слушающего порт 3000, шаг повторяет попытки и завершается ошибкой `attempt 10/10: curl exit=7` (не удалось подключиться). Поднимите рядом с основной логикой минимальный HTTP-сервер, отвечающий кодом 200 на `GET /`:

    Node.js:

    ```javascript
    require('http').createServer((_req, res) => res.end('ok')).listen(3000)
    // дальше — основная логика приложения
    ```

    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()
    # дальше — основная логика приложения
    ```
- **Лок на 15 минут.** Один `/deploy` блокирует `/exec` и другие `/deploy`. Если предыдущий `/deploy` упал на шаге `healthcheck` или клиент оборвал соединение по таймауту, последующие `/exec` и `/deploy` будут возвращать `409 EXEC_BUSY` до автоматического истечения лока — то есть до 15 минут с момента старта прерванного деплоя. Ответ `409 EXEC_BUSY` несёт заголовок `Retry-After` и поля `retryable: true` / `retryAfter` (секунды) — это короткий интервал опроса, повторяйте с ним. Полный верхний предел до снятия лока — в `error.hint.autoExpiresInSeconds`. Что делать дальше, решает ВИД зависшего замка. Утёкший exec-мьютекс лечит гардированный [`POST /v1/infra/servers/:id/unstick`](/docs/infra/servers/unstick): запись держателя с `op: exec`, пережившая свой TTL, снимается им без `force`. Серверный лок прерванной ВЫКЛАДКИ таковым не является — законная выкладка живёт дольше своего TTL, поэтому там `/unstick` без `force` отвечает `409`, пока замок наблюдается, и остаётся ожидание. **Важно:** оба вызова требуют независимо подтверждённого простоя — состояния сервера и журнала: разрыв туннеля убивает всё, что выполняет агент. Безусловный [`DELETE /v1/infra/servers/:id/lock`](./lock.md) срезает ожидание только на реплике, которая ДЕРЖИТ лок: broadcast намеренно не снимает чужой живой deploy. Это последний шаг, и он допустим только после независимо подтверждённого простоя. Пересоздавать сервер не нужно.
- **Долгий деплой и таймауты HTTP-клиента.** Полный цикл — особенно с установкой `runtime` и большим `npm install` / `pip install` — реально занимает несколько минут. В JSON-режиме сервер удерживает соединение, отправляя HTTP 200 + chunked-пробелы каждые 15 сек до готовности (поэтому `Content-Length` отсутствует, а ответ выглядит «висящим»). Пробелы идут внутри уже открытого JSON-объекта, поэтому тело с первого байта выглядит как JSON (`{`). Окно платформы — **660 секунд**: дольше соединение не удерживается. Таймаут HTTP-клиента ставьте **не меньше 690 секунд** — строго выше окна платформы, потому что равный ему таймаут состязается с самой платформой и ответ можно потерять. Если клиент оборвёт соединение раньше (например, `curl --max-time 120` или значение по умолчанию в SDK — в ряде клиентов это 300 секунд), запрос на сервере всё равно продолжит выполняться. Симптомы такого обрыва: клиент получил `200` и заголовки, а тело пустое или не разбирается как JSON — ответ оборвался на удерживающих пробелах. Обрыв на этапе пробуждения выглядит иначе: ответа нет вовсе, потому что статус и заголовки уходят клиенту только после того, как машина проснулась. Сама операция уже видна через [`GET /v1/infra/servers/:id/operations`](./operations.md): найдите там запуск и после появления id читайте адресный [исход выкладки](./operation-status.md). Если wake успел ответить отказом, его HTTP-ответ несёт id в `X-Vibe-Operation-Id` и `error.operationId`; при fail-open записи id может не быть, и это не доказывает, что запуск не состоялся. Последующие `/exec` и `/deploy` отвечают `409 EXEC_BUSY` до завершения серверной части или до истечения 15-минутного лока. Что делать: (а) в curl — `--max-time 690` или больше, (б) в любом SDK — выставить таймаут запроса ≥ 690 секунд для этого эндпоинта, (в) или используйте `?stream=true` (SSE) — там события идут построчно по мере прохождения шагов, прокси и SDK таймауты ведут себя стабильнее. Оставшийся после такого обрыва серверный лок [`POST /v1/infra/servers/:id/unstick`](/docs/infra/servers/unstick) не снимет: он гардирован и, пока замок наблюдается, без `force` отвечает `409`, а сам собой снимает только exec-мьютекс, переживший собственный TTL. Остаётся дождаться истечения 15 минут либо перейти к безусловному [`DELETE /lock`](./lock.md) — он остаётся последним шагом и только после независимо подтверждённого простоя. Повторять деплой, не прочитав исход, нельзя: второй запуск ляжет поверх приложения, которое уже могло подняться. **Спящий сервер добавляет к этому окну до 6.5 минут:** пробелы начинают идти только после пробуждения, поэтому первый деплой после сна встречает до 6.5 минут тишины ещё до 660-секундного окна платформы. Ставьте таймаут с этим запасом либо будите машину заранее вызовом [`/wake`](/docs/infra/lifecycle/wake) с `wait=true`. При живой операции смены тарифа (`resize`) спящий сервер получает `409 EXEC_BUSY` до пробуждения; дождитесь её завершения. Не используйте ручное пробуждение для обхода resize.
- **`apt-get` в `preStart` работает без `sudo`.** Агент и команды деплоя выполняются от имени `root` — `sudo` писать не нужно и это не вызовет ошибку.
- **Само приложение работает под непривилегированной учётной записью.** Команды деплоя (`install`, `preStart`) и `/exec` — от `root`, но процесс приложения, который поднимает systemd, запускается от выделенного пользователя `vibeapp`. Он владеет каталогом деплоя (`extractTo`, по умолчанию `/opt/app`) и `$HOME` указывает туда же, поэтому запись внутрь своего каталога, кеши `npm`/`pip` и файлы данных работают без изменений. Что перестаёт работать: запись за пределами своего каталога, если этот каталог не объявлен в `dataDirs` (см. следующий пункт), подключение к базе, доступной только через рут-сокет (`MySQL` с `auth_socket`), и запуск `docker` из команды `start`. Порт ниже 1024 работает — платформа выдаёт для него отдельное разрешение.
- **Каталог состояния вне `extractTo` объявляется полем `dataDirs`.** Чистый деплой стирает `extractTo`, поэтому долгоживущие данные держат в отдельном каталоге — например `/opt/data`. Создаёт его `root` (ваш шаг `install` или `preStart`, либо сама платформа), поэтому под непривилегированным запуском приложение туда не запишет, пока каталог не объявлен. Передайте в теле деплоя `"dataDirs": ["/opt/data/state"]` — платформа создаст каталог и передаст его во владение `vibeapp` на каждом деплое, а при откате хардненинга вернёт владение обратно. Путь — абсолютный, нормализованный, вложенный в `/opt`, `/srv` или `/var/lib`. Сами корни (`/opt`, `/var/lib`) отвергаются с кодом `INVALID_DATA_DIRS` ещё до старта деплоя. Каталогов можно объявить до восьми.

  Передаётся **сам каталог, а не его содержимое** — этого достаточно, чтобы приложение создавало там свои файлы. Файлы, которые `root` положил внутрь раньше, остаются за `root`: приложение не сможет их переписать. Если приложению нужно менять именно их (заранее развёрнутая база, дерево из образа), добавьте `"dataDirsRecursive": true`.

  **Что важно знать про права.** Владелец каталога может удалить или заменить любой файл внутри него — включая те, что положил `root`. Поэтому не объявляйте каталог, в котором лежат скрипты, запускаемые потом от `root` (например, скрипт восстановления базы, который вы запускаете через `/exec`), или учётные данные, к которым приложению доступа быть не должно. Держите такое в необъявленном каталоге — именно поэтому в примерах объявляется подкаталог `/opt/data/state`, а не сам `/opt/data`: в `/opt/data` наши же рецепты кладут служебные скрипты, запускаемые от `root`, и файл с паролем и правами `0600`. По этой же причине `dataDirsRecursive` для `/opt/data` не принимается вовсе.

  Если деплой не хардненный (`"hardening": "off"`, `"systemd": false` или путь `extractTo` вне разрешённых корней), поле не делает ничего — приложение и так работает с правами администратора. Отдельный шаг `data_dirs` в этом случае возвращает `warning` и говорит об этом прямо. У галактического приложения поле игнорируется: там долгоживущие данные живут в томе `/data`.
- **Файл, загруженный через `/upload` после деплоя, принадлежит `root`.** Загрузка выполняется агентом с правами администратора, а каталог деплоя после деплоя принадлежит `vibeapp` — поэтому дописать или перезаписать такой файл приложение не сможет. Если приложение должно менять загруженный файл, положите его в архив исходников или передайте владение в `preStart` следующего деплоя.

  Если приложение под этим режимом не проходит `healthcheck`, деплой **автоматически** возвращает прежний режим (юнит и владение каталогом) и завершается успешно, добавив шаг `hardening` со статусом `warning` и хвостом журнала с причиной. Терять деплой из-за хардненинга не придётся.

  Откат возвращает права и владение каталогом и на этом заканчивается: требование к команде `start` он не отменяет. Команда, которая запускает демон и выходит, после отката ведёт себя так же — сервис гаснет вместе с ней независимо от того, под какой учётной записью запущен. Шаг `hardening` в ответе означает смену режима запуска, а не причину, по которой приложение не отвечает.

  Чтобы пропустить попытку сразу — передайте `"hardening": "off"` в теле деплоя. Это осмысленно для приложений, которым права администратора нужны по существу: `nginx` в качестве команды `start`, `MySQL` через рут-сокет, запуск через `docker`. Приложение на Docker Compose, которое поднимает команда `start`, проходит и в этом режиме: шаг `stop_existing` останавливает контейнер приложения, а после отката команда `start` запускает его заново с правами администратора.
- **Команды `install`, `preStart` и `start` выполняются через POSIX-шелл `/bin/sh`.** Пишите их в POSIX-совместимом синтаксисе. Для остановки при первой же ошибке подойдёт `set -e`, для условий — `[ ... ]`. Опция `set -o pipefail` доступна только в bash — если она нужна, вызовите bash явно: `bash -c 'set -o pipefail; команды'`. То же для отдельного скрипта, запускаемого из `start`, — начните его строкой `#!/bin/bash` или запускайте через `bash script.sh`.
- **`docker-compose-plugin` отсутствует в стандартных репозиториях Ubuntu — устанавливайте Docker через официальный скрипт.** Пакет `docker-compose-plugin` — это компонент Docker CE из репозитория docker.com, которого нет в стандартных Ubuntu 24.04 apt-источниках. `apt-get install docker-compose-plugin` завершится ошибкой `Unable to locate package`. Правильный способ поставить Docker Engine + Compose V2 одной командой:
  ```
  "preStart": "curl -fsSL https://get.docker.com | sh && systemctl enable --now docker"
  ```
  После этого `docker compose` (без дефиса) доступен как плагин. Если нужен только Compose V1 (устаревший, только для совместимости), он есть в Ubuntu repos: `apt-get install -y docker-compose`.
- **`cleanDeploy: true` очищает только `extractTo`, не всю систему.** По умолчанию удаляется директория `/opt/app` перед распаковкой нового архива. Системные пакеты (`apt`), базы данных, файлы в `/var/lib/`, `/etc/`, `/root/` не затрагиваются — они переживут любое число повторных деплоев.
- **Системные пакеты сохраняются при sleep/wake, reboot и repair.** Все эти операции сохраняют диск виртуальной машины нетронутым. Повторно устанавливать ПО через `preStart` при каждом деплое не нужно — достаточно сделать это однажды. Последующие деплои без `runtime` и без нужных `preStart`-команд найдут пакеты на месте.
- **`/etc/systemd/system/` доступен для записи.** Можно создавать кастомные systemd-юниты через `preStart` или `/exec`: `systemctl daemon-reload && systemctl enable --now myservice`. Стандартный юнит деплоя (`app.service`) не конфликтует с пользовательскими юнитами при разных именах.
- **При смене `serviceName` между деплоями остановите старый сервис вручную.** Шаг `stop_existing` останавливает только сервис с текущим именем `serviceName`. Если предыдущий деплой прошёл с именем по умолчанию `app`, а следующий — с именем `dedup`, то команда `systemctl stop dedup` не находит прежний `app.service`. Прежний сервис продолжает работать и удерживает порт приложения — по умолчанию 3000. Новый сервис не может занять порт и уходит в цикл перезапусков. Шаг `healthcheck` распознаёт это состояние, включая случай, когда на запрос отвечает кодом 200 прежний процесс: деплой завершается ошибкой. Сообщение об ошибке называет номер порта и процесс-держатель порта, а в случае цикла перезапусков — ещё и состояние сервиса. Команду для освобождения порта — `fuser -k <порт>/tcp` — оно предлагает не всегда: если новый сервис остался запущенным, а порт держит процесс, который был на нём ещё до начала деплоя, сообщение говорит именно это и команды не даёт, потому что держателем может оказаться нужный вам сервис. Посторонний процесс платформа не завершает. Исключение одно — контейнер Docker Compose самого приложения, его шаг `stop_existing` останавливает. Освободите порт командой `fuser -k <порт>/tcp`, остановите прежний сервис командой `systemctl stop <старое-имя>.service` через [`/exec`](./exec.md) или верните прежнее значение `serviceName`, затем повторите деплой. Оговорка: когда диагностическая проверка порта не смогла отработать, ответ со статусом 200–399 засчитывается и деплой завершается успешно — кроме случая, когда проверка контейнеров Docker Compose из каталога `extractTo` уже нашла один из отказов, перечисленных у шага `healthcheck`.
- **Используйте `debug: true` при расхождениях `/deploy` и ручного пути.** Добавляет шаг `debug_info` с `sha256sum` файлов и `file -i` — помогает сравнить состояние `/deploy` с ручным `/upload` + `/exec` в байтах. Типичный случай — Tailwind v4 oxide падает на неожиданном UTF-8.
- **Тип архива определяется автоматически, отдельного поля формата нет.** Для `source.content` платформа читает сигнатуру первых байт (`PK` → zip, `1F 8B` → tar.gz, `BZ` → tar.bz2), для `source.url` — расширение пути. Передавать тип явным полем не нужно и негде. **Опираться на разбор нераспознанного архива нельзя.** Распознанный формат теперь выбирает и способ доставки архива на машину, поэтому «формат не распознали — прочитаем как tar.gz» из обещания превратилось в последнюю попытку прежнего пути доставки. Собирайте архив как tar.gz (`tar -czf app.tar.gz -C ./my-app .`) или zip (`zip -r app.zip .`) — тогда результат не зависит от того, какой путь доставки выберет платформа.
- **Для загрузки кода предпочтителен tar.gz.** Оба формата распаковываются, но zip, собранный средством `Compress-Archive` из PowerShell, хранит пути с обратной косой чертой `\`. Вложенные файлы из такого архива могут оказаться в корне `extractTo` вместо своих папок, и часть кода не попадёт в нужное место. Шаг `normalize_windows_paths` приводит такие пути к обычному виду, но чтобы исключить проблему, соберите код в tar.gz: `tar -czf app.tar.gz -C ./my-app .`. Если нужен zip — упакуйте его Unix-инструментом: `zip -r app.zip .`.
- **Архив `source.url` для отдельной VM в новом режиме скачивает платформа.** Для серверов, включённых в rollout, она проверяет конечный адрес каждого соединения и редиректа, читает архив потоком и передаёт на VM ограниченными блоками через существующий туннель. Поэтому доступность источника не зависит от egress/NAT конкретной VM. Временные DNS/TCP/TLS/HTTP/body-отказы получают до четырёх попыток с backoff в общем бюджете пяти минут. Распаковка выполняется один раз после проверки размера и SHA-256. До включения либо на агенте без relay используется совместимая VM-download ветка. При выключенном kill switch она сохраняет исторический URL/timeout/error-контракт. Стабильные очищенные ошибки относятся к новому режиму.
- **Крупные inline-архивы конкурируют за слот.** `source.content` больше ~10 КБ держит запрос в памяти на всё время деплоя, поэтому такие запросы ограничены небольшим числом одновременных (по умолчанию 2 на бэкенд). При превышении приходит `429` с подсказкой переключиться на `source.url` или `source.versionId`: они не занимают этот inline-memory слот, но пул загрузки URL через платформу ограничен отдельно. При насыщении он возвращает `DOWNLOAD_RELAY_BUSY` после успешно завершившегося `runtime`, если он был передан, но до `stop_existing` и `clean`: оркестратор ещё не останавливал юнит и не очищал дерево приложения. Установщик рантайма уже мог изменить системные зависимости хоста, и эти изменения не откатываются. Мелкие inline-архивы и `versionId` слот не занимают.
- **Деплой с внешнего URL требует снимок исходников или явный отказ.** Когда на портале включено [хранилище исходников](/docs/source-storage), деплой с `source.url`, который указывает не на хранилище Вайбкод, возвращает `409 SNAPSHOT_REQUIRED` — так история версий приложения не теряется при деплое со стороннего адреса. Есть два пути. Передать заголовок `X-Skip-Source-Snapshot: <причина ≤200 символов>` — деплой продолжится без сохранения снимка. Либо сначала загрузить архив через `POST /v1/apps/:id/sources` и развернуть его через `{ "source": { "versionId": "vN" } }` — тогда платформа сохранит версию. Источник из самого хранилища — `source.versionId` или `source.content` — этого заголовка не требует. Подробнее — [Хранилище исходников](/docs/source-storage).
- **Nginx в `static`, `php83` и `php83-mysql`.** На отдельной виртуальной машине рантайм устанавливает пакет до остановки прежнего приложения, но не запускает системный `nginx.service` и не оставляет его впервые включённым. Для `static` процесс, слушающий порт, запускает управляемый `app.service`; PHP-рецепты сохраняют собственные команды запуска.
- **`static`** — рантайм для одностраничных приложений и статических сайтов. Передайте архив с готовой сборкой, `runtime: "static"` и нужный `port` (по умолчанию `3000`); `start` необязателен и игнорируется во всех размещениях. Остальное зависит от размещения. На отдельной виртуальной машине с обычным `systemd: true` платформа создаёт nginx-конфиг, раздаёт `extractTo` (по умолчанию `/opt/app`) и запускает nginx под непривилегированной учётной записью; document root должен быть вложенным прикладным каталогом под `/opt` или `/srv`, а все пути под `/var/lib` и `/opt/data` отвергаются. При `systemd: false` платформа не создаёт ни nginx-конфиг, ни unit: заранее подготовленный unit должен сам слушать выбранный `port`. В Galaxy файлы всегда лежат в `/opt/app` внутри контейнера, `extractTo` и standalone-настройки игнорируются, а nginx запускается фиксированной командой контейнера. Управляемые конфигурации в обоих размещениях возвращают `index.html` для клиентских маршрутов, проверяют всю цепочку пути на симлинки и не отдают скрытые файлы, включая `.env`. При multipart-деплое поле `port` по-прежнему обязательно.
- **Galaxy-приложению свой `Dockerfile` не нужен — платформа генерирует его сама.** Из полей деплоя `runtime` и `port`, а для рантаймов кроме `static` также `start`, платформа собирает `Dockerfile` с базовым образом и строкой `EXPOSE <port>`. Для остальных рантаймов `CMD` выводится из `start`; static получает платформенную команду nginx и игнорирует `start`. Класть собственный `Dockerfile` в архив не требуется и не нужно. Это касается только galaxy-приложения — поле `createdVia` равно `galaxy`.
- **Ошибка сборки `published no host port for <port>` означает, что контейнер не удержался на `port`, а не отсутствие `EXPOSE`.** У galaxy-приложения этот текст приходит, когда контейнер упал на старте, команда `start` неверна или приложение слушает другой порт — то есть на `port` никто не отвечает. Поправьте `start` и `port` так, чтобы приложение действительно слушало `port`. Добавлять `EXPOSE` вручную не нужно: платформа уже выставила его из `port`.
- **Переменные `env` galaxy-приложения подставляются в контейнер при запуске, а не вшиваются в образ.** Платформа передаёт их флагом `docker run --env` на старте контейнера, поэтому секреты из `env` не попадают в слои образа и не видны в его истории. Это касается только galaxy-приложения — поле `createdVia` равно `galaxy`.
- **Деплой galaxy-приложения без пользовательского `env` сбрасывает переменные окружения и возвращает `warnings[]`.** Контейнер пересоздаётся при каждом деплое, а переменные передаются только аргументами его запуска в текущем запросе. Если в `/deploy` поле `env` не передано, передано как `{}` или содержит только платформенный `PORT`, контейнер владельца стартует без прежних пользовательских переменных. Успешный ответ предупреждает об этом и просит передавать полный набор `env` в каждом деплое. `preserveEnv` для galaxy-приложения игнорируется и предупреждение не скрывает. Исключение — деплой участника команды разработки: платформа сохраняет уже заданные владельцем переменные, поэтому предупреждения о сбросе нет. Присланный участником `env` игнорируется с отдельной строкой. Стратегии сохранения `.env` — `preserveEnv`, файл вне `extractTo`, `cleanDeploy: false` — доступны только для отдельной виртуальной машины.
- **Архив из хранилища исходников хост галактики скачивает сам, и причина отказа называется отдельно.** Когда деплой идёт снимком `source.versionId`, а архив собран как tar.gz, машина скачивает его сама и на месте сверяет размер и контрольную сумму с сохранённой версией. Если скачать или распаковать не удалось, приходит `502 GALAXY_APP_BUILD_FAILED`, а хвост `buildLog` открывается маркером причины: `UPLOAD_DOWNLOAD_FAILED` — не скачалось, `UPLOAD_EXTRACT_FAILED` — не распаковалось, `UPLOAD_NO_SPACE` — на хосте нет места. Маркеры ложатся в `error.category` как `SOURCE_FETCH_FAILED`, `SOURCE_EXTRACT_FAILED` и `RESOURCE` — что делать с каждой причиной, сказано в таблице категорий ниже. Единственное, чего там нет: на нехватке места уменьшать архив бессмысленно, место занято не вами. Скачивание хостом включается по аккаунтам, а zip и архивы сверх потолка ссылки идут прежним путём — там причина приходит одним сообщением о неудачной распаковке.
- **Ошибки сборки и старта galaxy-приложения приходят с разбором причины.** У ответов `502 GALAXY_APP_BUILD_FAILED` и `502 GALAXY_APP_START_FAILED` платформа разбирает лог сборки и, когда причина распознана, добавляет в объект `error` два поля: `category` — машинный код причины, `buildHint` — рекомендация на языке ключа. Возможные значения `category`:

  | Значение | Что произошло |
  |----------|---------------|
  | `NO_SOURCE` | Слот приложения создан, код так и не загружен |
  | `DEPLOY_INCOMPLETE` | Загрузка кода не завершилась — сборка прервалась или хост был недоступен |
  | `SOURCE_FETCH_FAILED` | Архив не удалось скачать на машину — ссылка протухла, связь оборвалась или содержимое не совпало с сохранённой версией. Отправьте выкладку ещё раз: ссылка выписывается заново |
  | `SOURCE_EXTRACT_FAILED` | Архив скачан, но распаковать его не удалось — битый архив либо члены, ведущие за пределы каталога распаковки |
  | `EMPTY_CONTEXT` | Архив распаковался в пустой контекст — файлов приложения в нём нет |
  | `BUILD_TIMEOUT` | Сборка не уложилась в отведённое время |
  | `PORT_NOT_PUBLISHED` | Контейнер не занял `port` — приложение завершилось сразу при старте |
  | `RUNTIME_CRASH` | Контейнер собрался, но не остался живым после старта |
  | `RUNTIME_OOM` | Контейнер исчерпал память после старта — упёрся в лимит памяти галактики |
  | `BUILD_OOM` | Сборке образа не хватило памяти — шаг установки упёрся в лимит памяти сборки, это половина памяти хоста галактики. Подготовьте тяжёлую компиляцию заранее или выберите тариф галактики побольше. Отказ терминальный — платформа его не повторяет |
  | `RESOURCE` | На хосте не осталось свободного места — при скачивании архива на машину либо во время самой сборки. По HTTP нехватка места на сборке приходит отдельным кодом `502 GALAXY_LOW_DISK`, повторяемым и не помечающим приложение сломанным, а на карточке сервера остаётся эта категория |
  | `BASE_IMAGE_UNAVAILABLE` | Склад базовых образов был недоступен на шаге получения основы для сборки. Состояние временное — тот же деплой проходит после повтора |
  | `HOST_UNAVAILABLE` | Хост галактики был недоступен во время сборки |
  | `NO_PACKAGE_JSON` | Команда установки задана явно, а `package.json` в корне архива нет |
  | `MODULE_NOT_FOUND` | Модуль не найден — путь запуска в `start` или отсутствующая зависимость |
  | `INSTALL_FAILED` | Установка зависимостей завершилась ошибкой |
  | `INSTALL_AUTH` | Реестр пакетов вернул 401 или 403 — нет доступа к приватному пакету |
  | `INSTALL_REGISTRY_UNAVAILABLE` | Каталог пакетов Python или Node.js либо каталог Debian не ответил во время установки зависимости или системного пакета. Отказ терминальный — платформа его не повторяет |
  | `NATIVE_PREBUILD_UNAVAILABLE` | Готовую сборку зависимости с машинным кодом скачать не удалось: внешний источник не ответил, а собрать её из исходников в образе приложения нечем. Состояние временное — тот же деплой проходит после повтора. Отказ терминальный: платформа его не повторяет, повторите деплой сами через несколько минут |

  Когда причину распознать не удалось, обоих полей в ответе нет — остаются `code`, `message` и `buildLog`. Для ветвления в коде используйте `category`, для показа пользователю — `buildHint`.
- **У ответа `502 GALAXY_HOST_UNREACHABLE` есть поле `error.hint` с планом восстановления.** Это объект из четырёх строк: `reason` — почему хост сейчас недоступен, `recovery` — что делать, `recoveryAction` — следующий безопасный вызов, `note` — оговорка о том, что статус хоста в выдаче может отставать от реального состояния туннеля. Для deploy есть две ветки. При `retryable: false` сборка уже была отправлена, а устойчивый исход — `unknown`: `recoveryAction` ведёт в список операций, не повторяйте deploy вслепую. Без `retryable: false` отказ произошёл до отправки сборки, и подсказка разрешает повтор после восстановления туннеля. **Удалять и пересоздавать приложение не нужно** — слот, контейнер и том `/data` целы.
- **Спящую отдельную виртуальную машину деплой будит сам и дожидается её в том же запросе.** Если сервер (`kind: "STANDALONE"`) в статусе `sleeping`, деплой запускает пробуждение и ждёт готовности до 6.5 минут: 5 минут на запуск машины и подключение туннеля, затем ещё 90 секунд ожидания без перезагрузки. Уложился — деплой идёт дальше тем же запросом, отдельный вызов [`/wake`](/docs/infra/lifecycle/wake) не нужен. Не уложился — приходит `503 WAKE_TIMEOUT`, **а сервер возвращается в `sleeping`** — повторный запрос безопасен и начинает пробуждение заново. Запрет на пробуждение деплой не обходит: при замороженном балансе запрос отбивается ещё до выкладки кодом `402 ACCOUNT_FROZEN`, а при остальных причинах — например, окончании доступа к платформе или административной блокировке — приходит `403 SERVER_WAKE_BLOCKED`. Повтор в обоих случаях не поможет, пока состояние не изменится. Так же ведут себя [`/exec`](./exec.md), [`/upload`](./upload.md) и [`/logs`](./logs.md). При живой операции смены тарифа (`resize`) спящий сервер получает `409 EXEC_BUSY` до пробуждения; дождитесь её завершения.
- **Спящую галактику деплой будит сам, и на первый запрос может не хватить времени.** Если хост галактики спит, деплой запускает пробуждение в фоновом режиме и ждёт подключения туннеля примерно 4 минуты. Холодный хост чаще всего укладывается в это окно, и деплой проходит одним запросом. Если не уложился до отправки сборки, приходит `502 GALAXY_HOST_UNREACHABLE` без `retryable: false`, **а хост продолжает подниматься** — платформа не откатывает пробуждение; после восстановления туннеля действуйте по `error.hint`. Если ответ несёт `retryable: false`, сборка уже была отправлена и её исход `unknown`: сначала прочитайте список операций, состояние сервера и логи, не повторяйте deploy вслепую. Заморозку по биллингу и остановленный вручную хост платформа не будит — там повтор не поможет, пока состояние не изменится.

## Путь загрузки занят

Другая загрузка использует путь исходников. Синхронный деплой отвечает `409 GALAXY_UPLOAD_BUSY` с `error.retryable: true`, `error.retryAfter: 10`, таким же заголовком `Retry-After: 10` и `error.hint`. Подождите не менее 10 секунд и повторите тот же `POST /v1/infra/servers/:id/deploy` для этого приложения.

Сам отказ сохраняет статус приложения и уже работающую версию. Если сборка началась в фоне после создания сервера, новое приложение сначала остаётся в `provisioning`: ответ сервера и карточка в кабинете показывают `buildHint` с советом повторить тот же деплой. Если попытка остаётся зависшей 20 минут, очередная проверка в течение следующих 5 минут переводит приложение в `error`, сохраняя причину занятого пути и `buildHint`. Повторите тот же деплой и после этого перехода: новая попытка возвращает такой слот в `provisioning`. Данные `/data` сохраняются. Фоновая сборка исходников после создания сервера автоматически не повторяется. Hermes повторяет свою загрузку не раньше чем через 3 минуты; прежний лимит ожидания 30 минут завершает попытку с сохранением причины занятого пути и подсказки. Удалять приложение, снимать блокировку или вызывать `/unstick` не нужно.

## Смотрите также

- [Список рантаймов](./runtimes.md)
- [Выполнить команду](./exec.md)
- [Логи сервиса](./logs.md)
- [Снять зависший лок](./lock.md)
- [Задать порт](./port.md)
- [Пробуждение по расписанию](/docs/infra/wake-schedules)
- [Хранилище исходников](/docs/source-storage)
