For AI agents: markdown of this page — /docs-content-en/infra/deploy/deploy.md documentation index — /llms.txt

Full application deploy

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

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

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

Deploy and application access to the Bitrix24 account

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

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

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

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

Galaxy application — deploy right after creation

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

Differences in the deploy for a Galaxy application:

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

The Galaxy application lifecycle is on the Galaxy application page. The "deploy in a single request on creation" scenario is on the Create server page.

Parameters

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

Request fields (body — JSON)

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

Request fields (body — multipart/form-data)

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

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

Deploy steps

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

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

Examples

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

curl — personal key

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

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

curl — inline archive (minimal set of fields)

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

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

curl — arbitrary stack without a runtime

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

curl — OAuth application

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

JavaScript — personal key

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

JavaScript — OAuth application

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

Response fields

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

Response headers

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

X-Vibe-Operation-Id: clz9k2m4x0001qw8h3f7d2n5p

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

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

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

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

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

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

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

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

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

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

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

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

Response example

A successful deploy (JSON):

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

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

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

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

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

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

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

SSE stream with ?stream=true:

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

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

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

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

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

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

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

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

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

Error response example

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

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

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

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

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

Errors

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

The HTTP status does not reflect the deploy's outcome. On a dedicated virtual machine (kind: "STANDALONE") the connection is held open for the whole deploy, and the 200 status is sent before it begins — so both a successful and a failed deploy arrive with the code 200. The failure marker is the success: false field and the error object in the response body. Check success in the body, not the HTTP status: a client that branches on the status will treat a failed deploy as successful. For a Galaxy application (kind: "GALAXY_APP") the same error arrives with the status 502.

In streaming mode (?stream=true) a step failure arrives as an SSE step event with status: "error" — it carries the name of the failed step. On an exception or a transport drop, a separate error event carries code and message. On a stuck exec mutex, the terminating error event carries code: "DEPLOY_FAILED", step, and hint, but no message. There is no success field in the stream — the failure marker for a step is status: "error" in its step event.

The full list of common API errors — Errors.

The platform passes the port in `PORT`

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

So ordinary code just works:

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

How it is delivered:

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

What to keep in mind:

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

Known specifics

  • The multipart archive ceiling depends on whether the archive streamed into storage. The former 500 MB applies only when three conditions hold at once: you reach the server as its direct owner, not through another application's card and not with a management key; the server is either not a Galaxy application, or link deploys for Galaxy applications are fully enabled for you; and source storage is enabled both platform-wide and for your Bitrix24 account. If even one of them fails, streaming does not kick in, the archive is buffered in memory whole, and its ceiling becomes the same as for an inline base64 body: about 72 MB of archive, refused with 413 INLINE_SOURCE_TOO_LARGE and the same error.hint. The path that does not depend on these conditions is to save the archive as a version through POST /v1/infra/servers/:id/sources and deploy it with {"source": {"versionId": "vN"}}: there the bytes stream and run into the source-version cap instead, 500 MB of archive. For how storage works, see Source storage.

  • In multipart mode the source version is created before the deploy starts. The archive is streamed into storage while the request is still being read, and the version is marked failed right away — a successful deploy switches it to success. So a failed deploy leaves a version in history with the status failed: that is not junk but an honest note saying "the bytes were accepted, the deploy did not get through". For what the history contains, see Source storage. If storage itself did not accept the archive, the deploy does not start at all and the request returns 502 SOURCE_DEPOT_UNAVAILABLE — the body is fine, a plain retry helps.

  • The deploy FILLS IN the Bitrix24 catalog card's name and description — it does not rename them. displayName is written only while it still equals the server's technical identifier, description only while it is empty. Pass both on the first deploy: without them the catalog keeps the technical identifier and the response returns warnings[] with a hint. If the field is already set, the value you send is not applied — the response says so in a separate warnings[] entry and points at PATCH /v1/infra/servers/:id, the only rename endpoint. A separate entry arrives when the APPLIED value lost its non-ASCII characters on the way in (Windows PowerShell without explicit UTF-8 serialization is the usual case): the deploy itself is normal, and the value reaches the card as is. A dropped field gets no such entry — it never reached the card. A repeat deploy with the same value is silent too: nothing is written, and fixing the charset of a string already on the card goes through the rename endpoint.

  • Non-ASCII characters in displayName and description from Windows PowerShell. Sent without explicit UTF-8 serialization, they reach the catalog card as question marks (?): the bytes are lost on the client side, before the request is sent. A ready-to-run call with UTF8.GetBytes — Windows / PowerShell and UTF-8.

  • A non-root healthPath means the deploy also checks the root. Once the given path answers, the platform probes / — the address Bitrix24 opens. A 5xx at the root ends the deploy with an error on the healthcheck step: a light endpoint such as /api/health answers 200 even when the application page is down, and such a deploy used to report success. A 4xx does not abort the deploy — an app_root step arrives with status warning: a server with no page at the root (an API-only backend, for instance) is a working shape. A probe that never reports back is also a warning, not an error. With the default healthPath (/) no extra request is made.

  • A non-root healthPath with a root appUrl produces a warnings[] entry. Bitrix24 opens the app at the linked application's appUrl, and the platform fills it with the bare subdomain origin — no path. If the app answers ONLY on the subpath, the health check is green (it probed exactly that subpath) while the Bitrix24 frame opens the root, where the app is not. The deploy response carries a warnings[] hint — act on it only if the root is genuinely not served: a probe on a subpath while the UI lives at the root (healthPath: "/health") is a normal, correct shape, and moving that path into appUrl would instead break the embed (the frame would open a JSON endpoint). If a subpath is genuinely unavoidable, fix both halves: deploy there AND move the same subpath into appUrl via PATCH /v1/apps/{id} (id from GET /v1/apps, matched on appUrl). The check runs only on POST /v1/infra/servers/:id/deploy, and only when appUrl points at THIS server's subdomain; server-creation responses do not carry it. Why the root is preferred and not merely tidier: after a user signs in, the gateway returns them to the subdomain root rather than to the subpath, so an app with a subpath in appUrl lands on its own 404 on the first visit of each session; a hash-router app additionally loses location.hash on the wake screen. This is a known gateway limitation and is being fixed separately.

  • A warning status on the tunnel_routing step does not mean the deploy failed. After a successful healthcheck, the platform verifies that the Gateway tunnel routes public traffic to the right port (set_port + agent probe). If the port has not been detected yet or the agent runs in fixed-port mode, the step gets status warning (not error) and the deploy is not aborted. The application's public URL may serve the Black Hole placeholder page for about a minute while the agent's port scanner detects the listening port. The placeholder goes away on its own if the previous port was released. If a live process still answers on the previous port, the scanner deliberately keeps it and will not switch on its own — set the port explicitly via PATCH /port or stop that process. If the agent has no port auto-detection at all (agentError: NO_SCANNER in the step text), call /repair.

  • The application code is passed via source.content, not via start/install/preStart. The command fields (start, install, preStart) are short shell commands with a 5000-character limit each (command in /exec is 10000). They are not meant for file contents: the base64 of a whole archive does not fit there and gets truncated. Put the code itself (tar.gz / zip, including a single file) in source.content — a base64 string of the archive, up to 96 MB per request body, which is about 72 MB of archive — or in source.url, and leave only the start command in start (for example node server.js). An archive larger than 72 MB cannot be sent inline and is refused with 413 INLINE_SOURCE_TOO_LARGE: send it as a link in source.url or as a saved version in source.versionId, both of which accept up to 500 MB of archive. On a Galaxy application both paths are available where link deploys are enabled for you — otherwise shrinking the archive is the only option left.

  • Four strategies for .env. The file is written to {extractTo}/.env (by default /opt/app/.env) and with cleanDeploy: true is deleted before the next deploy:

    1. Pass env on every /deploy — it is regenerated automatically (recommended).
    2. preserveEnv: true — the platform reads .env before the clean and writes it back at the env step. If the deploy aborts after the snapshot was created and the clean began, for example on archive download or dependency install, the saved copy is put back on disk best-effort. A runtime failure happens before the snapshot and clean: the platform does not read the previous .env or upload a copy back, and the file stays in place. Useful when the variables are not at hand at redeploy time. An env passed in the same request wins: the file is written anew and the saved copy is discarded. Dedicated virtual machine only (kind: "STANDALONE").
    3. Store .env outside extractTo — for example, /var/lib/myapp/.env. That directory is not touched by cleanDeploy. Specify the explicit path in start.
    4. cleanDeploy: false — an accumulating (merge) deploy, .env survives, but stale files from previous deploys remain along with it.

    The strategies above apply to a dedicated virtual machine. A Galaxy application's env behavior is covered in a separate entry below.

  • The platform writes the wake windows' timezone into .env itself. If a dedicated virtual machine (kind: "STANDALONE") has enabled scheduled wake windows and all of them declare one and the same timezone, the deploy adds to .env a TZ variable carrying that zone — the application receives it along with the other variables and uses it to compute local time. Your own TZ passed in the env field wins: the platform does not override it. With preserveEnv: true the zone is appended to the file restored at the env step when that file carried no TZ line. If the deploy aborts after the snapshot was created and the clean began, but before the env step, the saved copy is put back as it was, with no zone appended — the next successful deploy adds it. A runtime failure happens earlier: the previous file stays in place without being read or uploaded back. If the windows declare different zones, or there are no windows at all, TZ is not set. For a Galaxy application (kind: "GALAXY_APP") the zone is not injected — the container receives only the variables from the env field of that request.

  • Scheduled wake brings the server up; it does not keep it running. If scheduled wake is available for your Bitrix24 account, an enabled recurring window is a good fit for work at a fixed time, when a task with no inbound activity finishes before the server's explicit numeric data.sleepAfterMinutes; the schedule does not shorten that numeric timeout. Unless the server is in the SCHEDULE run mode, current writes reject setting null while windows are enabled, and reject schedule creation or update while null is stored — even for a disabled window. If an older record still holds null together with an effective schedule, the platform's post-window fallback applies — 15 minutes by default. Inbound HTTP requests and SSH activity reset the timer; cron, background processes, and the application's outbound traffic do not. Continuous or longer work requires { "sleepAfterMinutes": null } and no enabled wake window. The absence of a deploy warning — with null, or with windows already created — does not itself promise no-sleep behavior.

  • An app that itself calls the Vibecode V1 API needs its own key. If the deployed app talks to the Vibecode V1 API (/v1/deals, /v1/users, etc.), it sends its own personal vibe_api_ key in the X-Api-Key header. The platform does not issue that key automatically: the user creates it in the API Keys section (/keys; for a read-only dashboard, in READONLY mode with minimal scopes) and passes it to the app via the env parameter (for example env: { "VIBE_API_KEY": "<key>" }); the app reads process.env.VIBE_API_KEY and sends it as X-Api-Key. A 401 (MISSING_API_KEY / INVALID_API_KEY) means the app carries no valid key, not a plan problem. A personal key talks to Bitrix24 as its owner — keep the server accessPolicy at OWNER_ONLY, otherwise other users would see the key owner's data. If the app is embedded in Bitrix24 as a placement and shows per-user data, that is a different auth flow, see App authorization.

  • Automatic loading of .env in systemd. The unit declares EnvironmentFile=-{extractTo}/.env (the - sign makes the file optional). The application gets the variables itself via systemd. But with systemd: false or commands from install/preStart, env is not loaded — pass it explicitly.

  • If dependencies and devDependencies are mixed, npm install --production will not install packages from devDependencies. For cron scripts with tsx, ts-node, etc., drop --production or add them separately via preStart.

  • A repeat deploy with the same runtime reinstalls it. Omit the runtime parameter on repeat deploys — it saves 45+ seconds. The first deploy with runtime is required to install it.

  • The health check may not succeed in time. Up to 10 attempts at a 3-second interval — that is about 30 seconds in total. If the application takes longer to start, add a /health endpoint that responds 200 immediately, even before warm-up, or move the long initialization into preStart.

  • The start command stays in the foreground and holds the listener itself. On a dedicated virtual machine the platform runs it as a systemd unit of type simple: the unit counts as alive exactly as long as the process it launched has not exited. A command that hands the work to a background daemon and exits does not hold the unit — once it exits, the service moves to inactive and systemd stops whatever processes it left behind. The listener disappears along with them. Write a command that never returns control: node server.js, python3 app.py, nginx -g 'daemon off;'. A process manager inside start (pm2 start server.js --name app && pm2 save, and any other form that launches and then exits) does not work under this scheme, even when the runtime installed that manager for you.

    Such a command has two outcomes, both from the same cause. First, the health check finds no listener and the deploy fails with attempt 10/10: curl exit=7. Second, the check reaches the app while the background process is still alive, so the deploy finishes successfully and the app stops answering a few seconds later: the deploy response is green and there is no application on the port any more. What tells the two outcomes apart is the deploy result itself: a red deploy carrying attempt 10/10: curl exit=7 is the first one, a green deploy whose app goes silent a few seconds later is the second. The portOwner field of the healthcheck step does NOT separate them — unknown arrives in both: in the first the port-ownership probe never ran at all (there was no status code in the 200–399 range), in the second it did run and could not name your unit as the owner. service arrives only for a command that holds the port itself, so unknown on a green deploy confirms that the port is held by a leftover background process rather than by the unit.

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

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

    Node.js:

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

    Python:

    Python
    import http.server, threading
    
    class Health(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b'ok')
        def log_message(self, *args):
            pass
    
    threading.Thread(
        target=lambda: http.server.HTTPServer(('', 3000), Health).serve_forever(),
        daemon=True,
    ).start()
    # then — the application's main logic
  • A 15-minute lock. A single /deploy blocks /exec and other /deploy calls. If the previous /deploy failed at the healthcheck step or the client dropped the connection on timeout, subsequent /exec and /deploy calls return 409 EXEC_BUSY until the lock expires automatically — that is, up to 15 minutes from the start of the interrupted deploy. The 409 EXEC_BUSY response carries a Retry-After header and retryable: true / retryAfter (seconds) fields — this is a short poll interval, so keep retrying with it; the full upper bound until the lock clears is in error.hint.autoExpiresInSeconds. What comes next depends on the KIND of the stuck lock. A leaked exec mutex is cured by the guarded POST /v1/infra/servers/:id/unstick: a holder record with op: exec that outlived its TTL is released without force. The server lock of an interrupted DEPLOY is not that — a legitimate deploy outlives its TTL, so there /unstick without force answers 409 while the lock is observed and the wait stands. ⚠️ Both calls require an independently confirmed idle server — its state and its logs: the tunnel bounce kills whatever the agent is running. The unconditional DELETE /v1/infra/servers/:id/lock cuts the wait only on the replica that HOLDS the lock — the broadcast deliberately spares a live foreign deploy — and it stays the last step, allowed only once the server is independently confirmed idle. There is no need to recreate the server.

  • A long deploy and HTTP-client timeouts. A full cycle — especially with a runtime install and a large npm install / pip install — really takes several minutes. In JSON mode the server holds the connection, sending HTTP 200 + chunked spaces every 15 s until it is ready (so Content-Length is absent and the response looks "hung"). The spaces go inside the already-opened JSON object, so the body looks like JSON ({) from its very first byte. The platform window is 660 seconds: the connection is not held longer than that. Set the HTTP-client timeout to at least 690 seconds — strictly above the platform window, because a timeout equal to it races the platform itself and the response can be lost. If the client drops the connection earlier (for example, curl --max-time 120, or an SDK default, which in some clients is 300 seconds), the request still keeps running on the server. Symptoms of such a drop: the client got 200 and the headers while the body is empty or does not parse as JSON — the response broke off on the keep-open spaces. A drop during the wake looks different: there is no response at all, because the status and the headers reach the client only after the machine is up. The operation itself is already visible through GET /v1/infra/servers/:id/operations: find the run there, then use its id to read the addressable Deploy outcome. If wake returned a refusal, its HTTP response carries the id in X-Vibe-Operation-Id and error.operationId; after a fail-open insert there may be no id, and that does not prove that the run never started. Subsequent /exec and /deploy calls return 409 EXEC_BUSY until the server-side part finishes or the 15-minute lock expires. What to do: (a) in curl — --max-time 690 or higher, (b) in any SDK — set the request timeout to ≥ 690 s for this endpoint, (c) or use ?stream=true (SSE) — in that mode events arrive line by line as the steps progress, and proxy and SDK timeouts behave more reliably. A backend lock left behind by such a drop is not something POST /v1/infra/servers/:id/unstick can take away: it is guarded, answers 409 on its own while the lock is observed, and clears one by itself only when that lock is an exec mutex past its own TTL. What remains is to wait the 15 minutes out or move to the unconditional DELETE /lock — it stays the last step and only once the server is independently confirmed idle. Do not re-send the deploy before reading that outcome: a second run would land on top of an application that may already be up. A sleeping server adds up to 6.5 minutes to that window: the spaces start only after the wake, so the first deploy after sleep meets up to 6.5 minutes of silence before the platform's 660-second window even begins. Set the timeout with that margin, or wake the machine in advance with /wake and wait=true.

  • apt-get in preStart works without sudo. The agent and the deploy commands run as root — there is no need to write sudo and it will not cause an error.

  • The app itself runs under an unprivileged account. Deploy commands (install, preStart) and /exec run as root, but the application process that systemd starts runs as the dedicated vibeapp user. It owns the deploy directory (extractTo, /opt/app by default) and $HOME points there, so writing inside its own directory, npm/pip caches and data files all work unchanged. What stops working: writing outside its own directory unless that directory is declared in dataDirs (see the next item), connecting to a database reachable only through a root-owned socket (MySQL with auth_socket), and running docker from the start command. Ports below 1024 do work — the platform grants a separate permission for them.

  • A state directory outside extractTo is declared with the dataDirs field. A clean deploy wipes extractTo, so durable data belongs in a separate directory — /opt/data, for example. That directory is created by root (your install or preStart step, or the platform itself), so under an unprivileged start the app cannot write there until the directory is declared. Pass "dataDirs": ["/opt/data/state"] in the deploy body — the platform then creates the directory and hands it to vibeapp on every deploy, and hands ownership back if hardening is rolled back. The path must be absolute, already normalized and inside /opt, /srv or /var/lib; the bare roots (/opt, /var/lib) are refused with INVALID_DATA_DIRS before the deploy starts. Up to eight directories can be declared.

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

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

    If the deploy is not hardened ("hardening": "off", "systemd": false, or an extractTo outside the accepted roots) the field does nothing — the app already runs with administrator privileges. A separate data_dirs step then returns warning and says so explicitly. On a Galaxy application the field is ignored: durable data lives in the /data volume there.

  • A file uploaded through /upload after a deploy belongs to root. The upload is performed by the agent with administrator privileges, while the deploy directory belongs to vibeapp once the deploy finishes — so the app cannot append to or overwrite such a file. If the app is meant to modify an uploaded file, ship it inside the source archive or hand ownership over in the next deploy's preStart.

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

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

    To skip the attempt outright, pass "hardening": "off" in the deploy body. That is the right call for apps that genuinely need administrator privileges: nginx as the start command, MySQL over the root socket, or a Docker-driven start.

  • The install, preStart and start commands run through the POSIX shell /bin/sh. Write them in POSIX-compatible syntax. To stop on the first error, use set -e; for conditions, use [ ... ]. The set -o pipefail option is available only in bash — if you need it, call bash explicitly: bash -c 'set -o pipefail; commands'. The same applies to a separate script launched from start — begin it with the line #!/bin/bash or run it via bash script.sh.

  • docker-compose-plugin is missing from the standard Ubuntu repositories — install Docker via the official script. The docker-compose-plugin package is a Docker CE component from the docker.com repository, which is not in the standard Ubuntu 24.04 apt sources. apt-get install docker-compose-plugin fails with the error Unable to locate package. The correct way to install Docker Engine + Compose V2 in a single command:

    "preStart": "curl -fsSL https://get.docker.com | sh && systemctl enable --now docker"

    After that docker compose (no hyphen) is available as a plugin. If you only need Compose V1 (deprecated, for compatibility only), it is in the Ubuntu repos: apt-get install -y docker-compose.

  • cleanDeploy: true cleans only extractTo, not the whole system. By default the /opt/app directory is deleted before extracting the new archive. System packages (apt), databases, files in /var/lib/, /etc/, /root/ are not affected — they survive any number of repeat deploys.

  • System packages persist across sleep/wake, reboot, and repair. All these operations keep the virtual machine disk intact. There is no need to reinstall software via preStart on every deploy — doing it once is enough. Subsequent deploys without runtime and without the needed preStart commands find the packages in place.

  • /etc/systemd/system/ is writable. You can create custom systemd units via preStart or /exec: systemctl daemon-reload && systemctl enable --now myservice. The standard deploy unit (app.service) does not conflict with user units when the names differ.

  • When you change serviceName between deploys, stop the old service manually. The stop_existing step stops only the service with the current serviceName. If the previous deploy used the default name app and the next one uses dedup, then systemctl stop dedup does not find the previous app.service. The previous service keeps running and holds the app port — 3000 by default. The new service cannot bind to the port and goes into a restart loop. The healthcheck step recognizes this state, including the case where the previous process answers the request with code 200: the deploy fails. The error message names the port number and the process holding the port, and in the restart-loop case the state of the service as well. It does not always offer the command to free the port (fuser -k <port>/tcp): if the new service stayed up and the port is held by a process that was already on it before the deploy began, the message says exactly that and gives no command, because the holder may be a service you need. The platform does not terminate an unrelated process. Free the port with fuser -k <port>/tcp, stop the previous service with systemctl stop <old-name>.service via /exec, or restore the previous serviceName value, and then repeat the deploy. One caveat: when the port diagnostics could not run, a response with status 200–399 counts as success and the deploy completes successfully.

  • Use debug: true when /deploy and the manual path diverge. It adds a debug_info step with sha256sum of files and file -i — it helps compare the state of /deploy with manual /upload + /exec byte by byte. A typical case — Tailwind v4 oxide fails on unexpected UTF-8.

  • The archive type is detected automatically, there is no separate format field. For source.content the platform reads the signature of the first bytes (PK → zip, 1F 8B → tar.gz, BZ → tar.bz2), for source.url — the path extension. You do not need to pass the type as an explicit field — and there is no field to pass it in. Do not rely on an unrecognized archive being parsed. The recognized format now also selects how the archive is delivered to the machine, so "the format was not recognized, we will read it as tar.gz" turned from a promise into a last-resort fallback on the older delivery path. Package the archive as tar.gz (tar -czf app.tar.gz -C ./my-app .) or zip (zip -r app.zip .) — then the result does not depend on which delivery path the platform picks.

  • tar.gz is preferred for uploading code. Both formats extract, but a zip built with PowerShell's Compress-Archive stores paths with a backslash \. Nested files from such an archive may end up in the root of extractTo instead of their folders, and part of the code will not reach the right place. The normalize_windows_paths step brings such paths back to normal, but to avoid the issue, pack the code into tar.gz: tar -czf app.tar.gz -C ./my-app .. If you need zip — pack it with a Unix tool: zip -r app.zip ..

  • In the new mode, the platform downloads a dedicated VM's source.url archive. For servers enrolled in the rollout, it validates the destination of every connection and redirect, streams the response, and relays bounded chunks to the VM over the existing tunnel. Source availability therefore does not depend on that VM's egress or NAT path. Transient DNS/TCP/TLS/HTTP/body failures get up to four attempts with backoff inside a shared five-minute budget; extraction runs once after size and SHA-256 checks. Before enrollment, or when the agent does not support relay, a compatible VM-download path is used; while the kill switch is off it preserves the historical URL, timeout, and error contract. Stable sanitized failures apply to the new mode.

  • Large inline archives compete for a slot. A source.content larger than ~10 KB holds the request in memory for the whole deploy, so only a few such requests run concurrently (by default 2 per backend). When you exceed it, a 429 is returned with a hint to switch to source.url or source.versionId: they do not consume this inline-memory slot, but URL relay has its own bounded pool. When saturated it returns DOWNLOAD_RELAY_BUSY after a successful runtime, when one was passed, but before stop_existing and clean: the orchestrator has not stopped the unit or cleaned the application tree. The runtime installer may already have changed host dependencies, and those changes are not rolled back. Small inline archives and versionId do not take a slot.

  • A deploy from an external URL requires a source snapshot or an explicit opt-out. When source storage is enabled for your Bitrix24 account, a deploy with a source.url that does not point at Vibecode storage returns 409 SNAPSHOT_REQUIRED — this way the application version history is not lost on a deploy from a third-party address. There are two options. Pass the X-Skip-Source-Snapshot: <reason ≤200 characters> header — the deploy continues without saving a snapshot. Or first upload the archive via POST /v1/apps/:id/sources and deploy it via { "source": { "versionId": "vN" } } — then the platform saves the version. A source from storage itself — source.versionId or source.content — does not require this header. More details — Source storage.

  • A Galaxy application does not need its own Dockerfile — the platform generates it itself. From the deploy fields runtime and port, and for non-static runtimes start, the platform builds a Dockerfile with the runtime base image and an EXPOSE <port> line. Non-static CMD is derived from start; static uses the platform nginx command and ignores start. Putting your own Dockerfile in the archive is not required and not needed. This applies only to a Galaxy application — the createdVia field equals galaxy.

  • The build error published no host port for <port> means the container did not stay up on port, not that EXPOSE is missing. For a Galaxy application this text arrives when the container crashed on start, the start command is wrong, or the application listens on a different port — that is, nothing responds on port. Fix start and port so the application actually listens on port. There is no need to add EXPOSE manually: the platform already set it from port.

  • A Galaxy application's env variables are injected into the container at startup, not baked into the image. The platform passes them with the docker run --env flag when the container starts, so secrets from env do not end up in the image layers and are not visible in its history. This applies only to a Galaxy application — the createdVia field equals galaxy.

  • A Galaxy application deploy without user env resets environment variables and returns warnings[]. The container is recreated on every deploy, and variables are passed only as startup arguments from the current request. If /deploy omits env, sends {}, or sends only the platform-owned PORT, the owner's container starts without previous user variables; the successful response warns about the reset and asks for the complete env set on every deploy. preserveEnv is ignored for a Galaxy application and does not hide the warning. A development-team member's deploy is the exception: the platform preserves variables already set by the owner, so there is no reset warning; env sent by the member is ignored with a separate warning. The .env-preservation strategies — preserveEnv, a file outside extractTo, cleanDeploy: false — are available only for a dedicated virtual machine.

  • The galaxy host downloads an archive from source storage itself, and the cause of a failure is named separately. When the deploy runs from a source.versionId snapshot and the archive is packed as tar.gz, the machine downloads it itself and checks the size and the checksum against the saved version right there. If the download or the unpacking fails, 502 GALAXY_APP_BUILD_FAILED arrives and the buildLog tail opens with a cause marker: UPLOAD_DOWNLOAD_FAILED — it did not download, UPLOAD_EXTRACT_FAILED — it did not unpack, UPLOAD_NO_SPACE — the host is out of space. The markers land in error.category as SOURCE_FETCH_FAILED, SOURCE_EXTRACT_FAILED and RESOURCE — what to do about each cause is in the category table below. The one thing the table does not say: when the host is out of space, shrinking the archive is pointless — it is not your files that filled the disk. The host-side download is enabled per account, while zip archives and archives above the link size cap take the previous path — there the cause arrives as a single message about the failed extraction.

  • Galaxy build and start errors arrive with the cause diagnosed. For 502 GALAXY_APP_BUILD_FAILED and 502 GALAXY_APP_START_FAILED the platform parses the build log and, when the cause is recognized, adds two fields to the error object: category — a machine code for the cause, buildHint — a recommendation in the API key's language. The possible category values:

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

    When the cause could not be recognized, neither field is present — only code, message and buildLog remain. Branch in code on category, show buildHint to the user.

  • The 502 GALAXY_HOST_UNREACHABLE response has an error.hint field with a recovery plan. It is an object of four strings: reason — why the host is unreachable right now, recovery — what to do, recoveryAction — the next safe call, note — a caveat that the host status in the listing can lag behind the real tunnel state. Deploy has two branches. With retryable: false, the build was dispatched and its durable outcome is unknown: recoveryAction points to the operation list; do not repeat the deploy blindly. Without retryable: false, the refusal happened before build dispatch and the hint permits a retry after the tunnel recovers. There is no need to delete and recreate the application — the slot, container and /data volume are intact.

  • The deploy wakes a sleeping dedicated virtual machine itself and waits for it within the same request. If the server (kind: "STANDALONE") is in sleeping status, the deploy starts the wake and waits for readiness for up to 6.5 minutes: 5 minutes for the machine to start and the tunnel to connect, then another 90 seconds of waiting without a reboot. If it makes it in time, the deploy continues in the same request and a separate /wake call is not needed. If it does not, 503 WAKE_TIMEOUT arrives and the server returns to sleeping — a repeat request is safe and starts the wake over. The deploy does not bypass a wake block: with a frozen balance the request is rejected before the deploy with 402 ACCOUNT_FROZEN, and for the other causes — for example, expired platform access or an administrative block — 403 SERVER_WAKE_BLOCKED arrives. In both cases a retry will not help until that state changes. /exec, /upload and /logs behave the same way.

  • The deploy wakes a sleeping galaxy itself, and the first request may not have enough time. If the galaxy host is asleep, the deploy starts the wake in the background and waits for the tunnel to connect for about 4 minutes. A cold host usually fits into that window and the deploy goes through in a single request. If the wait times out before the build is dispatched, 502 GALAXY_HOST_UNREACHABLE arrives without retryable: false, and the host keeps booting — the platform does not roll the wake back; follow error.hint after the tunnel recovers. If the response carries retryable: false, the build was dispatched and its outcome is unknown: read the operation list, server state, and logs first; do not repeat the deploy blindly. A host frozen for billing or stopped manually is not woken — a retry will not help there until that state changes.

See also