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:
runtimeis required.startis required for every runtime exceptstatic; forstaticit is optional and ignored. Withoutruntimethe request returns400 GALAXY_DEPLOY_RUNTIME_REQUIRED.- The source is the inline
source.content(a base64 archive). Thesource.urlandsource.versionIdvariants are available where the platform has enabled link deploys for you; otherwise they are rejected with400 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 with400 GALAXY_SOURCE_URL_NOT_ALLOWED. A separate virtual machine has no such restriction. - A
Dockerfilein the archive is not required — the platform generates it itself fromruntimeandport, and for non-static runtimes fromstart. Static always gets the platform nginx command. If you put your ownDockerfilein the archive, it is ignored (the platform always generates its own). See "Known specifics" below. - The container working directory is
/opt/app(the same asextractToon 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/apppath also points to/opt/app— it is a symlink kept for backward compatibility. That is, the samestartworks for both VM and galaxy deploys. - The deploy is addressed by the application's own id (
kindequalsGALAXY_APP). The same request with the id of the host machine (kindequalsGALAXY) 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 intoinstall— it runs while the container is built. ThehealthPathfield does apply and sets the readiness probe path inside the container. TheextractToanddataDirspaths are still validated: an unacceptable value returns400 INVALID_EXTRACT_TOor400 INVALID_DATA_DIRS, and the same code comes back fordataDirsRecursivewithoutdataDirs.
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
# 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)
# 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
# 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
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
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
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:
"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):
{
"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:
{
"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):
{
"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):
{
"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 the200status is sent before it begins — so both a successful and a failed deploy arrive with the code200. The failure marker is thesuccess: falsefield and theerrorobject in the response body. Checksuccessin 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 status502.In streaming mode (
?stream=true) a step failure arrives as an SSEstepevent withstatus: "error"— it carries the name of the failed step. On an exception or a transport drop, a separateerrorevent carriescodeandmessage. On a stuck exec mutex, the terminatingerrorevent carriescode: "DEPLOY_FAILED",step, andhint, but nomessage. There is nosuccessfield in the stream — the failure marker for a step isstatus: "error"in itsstepevent.
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:
require('http').createServer(handler).listen(process.env.PORT, '0.0.0.0')
How it is delivered:
- Dedicated virtual machine (
kind: "STANDALONE") — theplatform_envstep writes a separate file.vibe-platform.envnext to your.env, holding a singlePORTline, 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.envis neither read nor rewritten in the process. - Galaxy application (
kind: "GALAXY_APP") —PORTis passed to the container as an environment variable at start, alongside your own variables.
What to keep in mind:
PORTis reserved by the platform. If you pass your ownenv.PORTthat differs from theportfield, the platform overrides it and says so with a line in the responsewarnings[]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; nowPORTbelongs to the platform and gets overridden, and thewarnings[]entry is about exactly that case. - An application that reads the
.envfile directly (instead of the process environment) will not findPORTthere: the platform value lives in a separate file. Readprocess.env.PORT— common loaders (dotenvand 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 lineEnvironmentFile=-<extractTo>/.vibe-platform.envafter the line with your.env. Until you do, yourenv.PORTkeeps 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 — theplatform_envstep returnswarning. - Already running applications receive
PORTon 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_LARGEand the sameerror.hint. The path that does not depend on these conditions is to save the archive as a version throughPOST /v1/infra/servers/:id/sourcesand 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
failedright away — a successful deploy switches it tosuccess. So a failed deploy leaves a version in history with the statusfailed: 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 returns502 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.
displayNameis written only while it still equals the server's technical identifier,descriptiononly while it is empty. Pass both on the first deploy: without them the catalog keeps the technical identifier and the response returnswarnings[]with a hint. If the field is already set, the value you send is not applied — the response says so in a separatewarnings[]entry and points atPATCH /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
displayNameanddescriptionfrom 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 withUTF8.GetBytes— Windows / PowerShell and UTF-8.A non-root
healthPathmeans 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 thehealthcheckstep: a light endpoint such as/api/healthanswers 200 even when the application page is down, and such a deploy used to report success. A 4xx does not abort the deploy — anapp_rootstep arrives with statuswarning: 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 awarning, not an error. With the defaulthealthPath(/) no extra request is made.A non-root
healthPathwith a rootappUrlproduces awarnings[]entry. Bitrix24 opens the app at the linked application'sappUrl, 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 awarnings[]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 intoappUrlwould 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 intoappUrlviaPATCH /v1/apps/{id}(id fromGET /v1/apps, matched onappUrl). The check runs only onPOST /v1/infra/servers/:id/deploy, and only whenappUrlpoints 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 inappUrllands on its own 404 on the first visit of each session; a hash-router app additionally loseslocation.hashon the wake screen. This is a known gateway limitation and is being fixed separately.A
warningstatus on thetunnel_routingstep does not mean the deploy failed. After a successfulhealthcheck, 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 statuswarning(noterror) 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 viaPATCH /portor stop that process. If the agent has no port auto-detection at all (agentError: NO_SCANNERin the step text), call/repair.The application code is passed via
source.content, not viastart/install/preStart. The command fields (start,install,preStart) are short shell commands with a 5000-character limit each (commandin/execis 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) insource.content— a base64 string of the archive, up to 96 MB per request body, which is about 72 MB of archive — or insource.url, and leave only the start command instart(for examplenode server.js). An archive larger than 72 MB cannot be sent inline and is refused with413 INLINE_SOURCE_TOO_LARGE: send it as a link insource.urlor as a saved version insource.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 withcleanDeploy: trueis deleted before the next deploy:- Pass
envon every/deploy— it is regenerated automatically (recommended). preserveEnv: true— the platform reads.envbefore the clean and writes it back at theenvstep. 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. Aruntimefailure happens before the snapshot and clean: the platform does not read the previous.envor upload a copy back, and the file stays in place. Useful when the variables are not at hand at redeploy time. Anenvpassed in the same request wins: the file is written anew and the saved copy is discarded. Dedicated virtual machine only (kind: "STANDALONE").- Store
.envoutsideextractTo— for example,/var/lib/myapp/.env. That directory is not touched bycleanDeploy. Specify the explicit path instart. cleanDeploy: false— an accumulating (merge) deploy,.envsurvives, but stale files from previous deploys remain along with it.
The strategies above apply to a dedicated virtual machine. A Galaxy application's
envbehavior is covered in a separate entry below.- Pass
The platform writes the wake windows' timezone into
.envitself. 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.envaTZvariable carrying that zone — the application receives it along with the other variables and uses it to compute local time. Your ownTZpassed in theenvfield wins: the platform does not override it. WithpreserveEnv: truethe zone is appended to the file restored at theenvstep when that file carried noTZline. If the deploy aborts after the snapshot was created and the clean began, but before theenvstep, the saved copy is put back as it was, with no zone appended — the next successful deploy adds it. Aruntimefailure 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,TZis not set. For a Galaxy application (kind: "GALAXY_APP") the zone is not injected — the container receives only the variables from theenvfield 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 theSCHEDULErun mode, current writes reject settingnullwhile windows are enabled, and reject schedule creation or update whilenullis stored — even for a disabled window. If an older record still holdsnulltogether 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 — withnull, 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 personalvibe_api_key in theX-Api-Keyheader. 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 theenvparameter (for exampleenv: { "VIBE_API_KEY": "<key>" }); the app readsprocess.env.VIBE_API_KEYand sends it asX-Api-Key. A401(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 serveraccessPolicyatOWNER_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
.envin systemd. The unit declaresEnvironmentFile=-{extractTo}/.env(the-sign makes the file optional). The application gets the variables itself via systemd. But withsystemd: falseor commands frominstall/preStart, env is not loaded — pass it explicitly.If
dependenciesanddevDependenciesare mixed,npm install --productionwill not install packages fromdevDependencies. For cron scripts withtsx,ts-node, etc., drop--productionor add them separately viapreStart.A repeat deploy with the same
runtimereinstalls it. Omit theruntimeparameter on repeat deploys — it saves 45+ seconds. The first deploy withruntimeis 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
/healthendpoint that responds 200 immediately, even before warm-up, or move the long initialization intopreStart.The
startcommand stays in the foreground and holds the listener itself. On a dedicated virtual machine the platform runs it as a systemd unit of typesimple: 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 toinactiveand 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 insidestart(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 carryingattempt 10/10: curl exit=7is the first one, a green deploy whose app goes silent a few seconds later is the second. TheportOwnerfield of thehealthcheckstep does NOT separate them —unknownarrives 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.servicearrives only for a command that holds the port itself, sounknownon 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
healthcheckandapp_rootsteps requesthealthPathand 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
healthcheckstep 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 errorattempt 10/10: curl exit=7(failed to connect). Alongside the main logic, bring up a minimal HTTP server that responds with code 200 toGET /:Node.js:
require('http').createServer((_req, res) => res.end('ok')).listen(3000) // then — the application's main logicPython:
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 logicA 15-minute lock. A single
/deployblocks/execand other/deploycalls. If the previous/deployfailed at thehealthcheckstep or the client dropped the connection on timeout, subsequent/execand/deploycalls return409 EXEC_BUSYuntil the lock expires automatically — that is, up to 15 minutes from the start of the interrupted deploy. The409 EXEC_BUSYresponse carries aRetry-Afterheader andretryable: true/retryAfter(seconds) fields — this is a short poll interval, so keep retrying with it; the full upper bound until the lock clears is inerror.hint.autoExpiresInSeconds. What comes next depends on the KIND of the stuck lock. A leaked exec mutex is cured by the guardedPOST /v1/infra/servers/:id/unstick: a holder record withop: execthat outlived its TTL is released withoutforce. The server lock of an interrupted DEPLOY is not that — a legitimate deploy outlives its TTL, so there/unstickwithoutforceanswers409while 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 unconditionalDELETE /v1/infra/servers/:id/lockcuts 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
runtimeinstall and a largenpm 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 (soContent-Lengthis 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 got200and 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 throughGET /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 inX-Vibe-Operation-Idanderror.operationId; after a fail-open insert there may be no id, and that does not prove that the run never started. Subsequent/execand/deploycalls return409 EXEC_BUSYuntil the server-side part finishes or the 15-minute lock expires. What to do: (a) in curl —--max-time 690or 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 somethingPOST /v1/infra/servers/:id/unstickcan take away: it is guarded, answers409on 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 unconditionalDELETE /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/wakeandwait=true.apt-getinpreStartworks withoutsudo. The agent and the deploy commands run asroot— there is no need to writesudoand it will not cause an error.The app itself runs under an unprivileged account. Deploy commands (
install,preStart) and/execrun asroot, but the application process that systemd starts runs as the dedicatedvibeappuser. It owns the deploy directory (extractTo,/opt/appby default) and$HOMEpoints there, so writing inside its own directory,npm/pipcaches and data files all work unchanged. What stops working: writing outside its own directory unless that directory is declared indataDirs(see the next item), connecting to a database reachable only through a root-owned socket (MySQLwithauth_socket), and runningdockerfrom thestartcommand. Ports below 1024 do work — the platform grants a separate permission for them.A state directory outside
extractTois declared with thedataDirsfield. A clean deploy wipesextractTo, so durable data belongs in a separate directory —/opt/data, for example. That directory is created byroot(yourinstallorpreStartstep, 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 tovibeappon every deploy, and hands ownership back if hardening is rolled back. The path must be absolute, already normalized and inside/opt,/srvor/var/lib; the bare roots (/opt,/var/lib) are refused withINVALID_DATA_DIRSbefore 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
rootput inside earlier stay withroot, 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
rootput there. So do not declare a directory that holds scripts you later run asroot(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/statesubdirectory rather than/opt/dataitself:/opt/datais where our own recipes put service scripts that are run asroot, along with a password file with0600permissions. For the same reasondataDirsRecursiveis not accepted for/opt/dataat all.If the deploy is not hardened (
"hardening": "off","systemd": false, or anextractTooutside the accepted roots) the field does nothing — the app already runs with administrator privileges. A separatedata_dirsstep then returnswarningand says so explicitly. On a Galaxy application the field is ignored: durable data lives in the/datavolume there.A file uploaded through
/uploadafter a deploy belongs toroot. The upload is performed by the agent with administrator privileges, while the deploy directory belongs tovibeapponce 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'spreStart.If the app fails its
healthcheckunder this mode, the deploy automatically restores the previous mode (both the unit and directory ownership) and finishes successfully, adding ahardeningstep withwarningstatus 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
startcommand. 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. Ahardeningstep 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:nginxas thestartcommand,MySQLover the root socket, or a Docker-driven start.The
install,preStartandstartcommands run through the POSIX shell/bin/sh. Write them in POSIX-compatible syntax. To stop on the first error, useset -e; for conditions, use[ ... ]. Theset -o pipefailoption 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 fromstart— begin it with the line#!/bin/bashor run it viabash script.sh.docker-compose-pluginis missing from the standard Ubuntu repositories — install Docker via the official script. Thedocker-compose-pluginpackage 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-pluginfails with the errorUnable 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: truecleans onlyextractTo, not the whole system. By default the/opt/appdirectory 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
preStarton every deploy — doing it once is enough. Subsequent deploys withoutruntimeand without the neededpreStartcommands find the packages in place./etc/systemd/system/is writable. You can create custom systemd units viapreStartor/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
serviceNamebetween deploys, stop the old service manually. Thestop_existingstep stops only the service with the currentserviceName. If the previous deploy used the default nameappand the next one usesdedup, thensystemctl stop dedupdoes not find the previousapp.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. Thehealthcheckstep 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 withfuser -k <port>/tcp, stop the previous service withsystemctl stop <old-name>.servicevia/exec, or restore the previousserviceNamevalue, 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: truewhen/deployand the manual path diverge. It adds adebug_infostep withsha256sumof files andfile -i— it helps compare the state of/deploywith manual/upload+/execbyte 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.contentthe platform reads the signature of the first bytes (PK→ zip,1F 8B→ tar.gz,BZ→ tar.bz2), forsource.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-Archivestores paths with a backslash\. Nested files from such an archive may end up in the root ofextractToinstead of their folders, and part of the code will not reach the right place. Thenormalize_windows_pathsstep 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.urlarchive. 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.contentlarger 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, a429is returned with a hint to switch tosource.urlorsource.versionId: they do not consume this inline-memory slot, but URL relay has its own bounded pool. When saturated it returnsDOWNLOAD_RELAY_BUSYafter a successfulruntime, when one was passed, but beforestop_existingandclean: 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 andversionIddo 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.urlthat does not point at Vibecode storage returns409 SNAPSHOT_REQUIRED— this way the application version history is not lost on a deploy from a third-party address. There are two options. Pass theX-Skip-Source-Snapshot: <reason ≤200 characters>header — the deploy continues without saving a snapshot. Or first upload the archive viaPOST /v1/apps/:id/sourcesand deploy it via{ "source": { "versionId": "vN" } }— then the platform saves the version. A source from storage itself —source.versionIdorsource.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 fieldsruntimeandport, and for non-static runtimesstart, the platform builds aDockerfilewith the runtime base image and anEXPOSE <port>line. Non-staticCMDis derived fromstart; static uses the platform nginx command and ignoresstart. Putting your ownDockerfilein the archive is not required and not needed. This applies only to a Galaxy application — thecreatedViafield equalsgalaxy.The build error
published no host port for <port>means the container did not stay up onport, not thatEXPOSEis missing. For a Galaxy application this text arrives when the container crashed on start, thestartcommand is wrong, or the application listens on a different port — that is, nothing responds onport. Fixstartandportso the application actually listens onport. There is no need to addEXPOSEmanually: the platform already set it fromport.A Galaxy application's
envvariables are injected into the container at startup, not baked into the image. The platform passes them with thedocker run --envflag when the container starts, so secrets fromenvdo not end up in the image layers and are not visible in its history. This applies only to a Galaxy application — thecreatedViafield equalsgalaxy.A Galaxy application deploy without user
envresets environment variables and returnswarnings[]. The container is recreated on every deploy, and variables are passed only as startup arguments from the current request. If/deployomitsenv, sends{}, or sends only the platform-ownedPORT, the owner's container starts without previous user variables; the successful response warns about the reset and asks for the completeenvset on every deploy.preserveEnvis 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;envsent by the member is ignored with a separate warning. The.env-preservation strategies —preserveEnv, a file outsideextractTo,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.versionIdsnapshot 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_FAILEDarrives and thebuildLogtail 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 inerror.categoryasSOURCE_FETCH_FAILED,SOURCE_EXTRACT_FAILEDandRESOURCE— 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_FAILEDand502 GALAXY_APP_START_FAILEDthe platform parses the build log and, when the cause is recognized, adds two fields to theerrorobject:category— a machine code for the cause,buildHint— a recommendation in the API key's language. The possiblecategoryvalues:Value What happened NO_SOURCEThe application slot was created, the code was never uploaded DEPLOY_INCOMPLETEThe code upload did not finish — the build was interrupted or the host was unreachable SOURCE_FETCH_FAILEDThe 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_FAILEDThe archive downloaded but could not be unpacked — a corrupt archive, or members pointing outside the extraction directory EMPTY_CONTEXTThe archive extracted to an empty context — it holds no application files BUILD_TIMEOUTThe build did not finish within its time limit PORT_NOT_PUBLISHEDThe container did not bind to port— the application exited right at startRUNTIME_CRASHThe container built but did not stay alive after start RUNTIME_OOMThe container ran out of memory after start — it hit the galaxy memory limit RESOURCEThe 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 categoryBASE_IMAGE_UNAVAILABLEThe 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_UNAVAILABLEThe galaxy host was unreachable during the build NO_PACKAGE_JSONAn install command was set explicitly, but there is no package.jsonat the archive rootMODULE_NOT_FOUNDA module was not found — the entry path in start, or a missing dependencyINSTALL_FAILEDDependency installation failed INSTALL_AUTHThe package registry returned 401 or 403 — no access to a private package INSTALL_REGISTRY_UNAVAILABLEThe 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_UNAVAILABLEThe 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,messageandbuildLogremain. Branch in code oncategory, showbuildHintto the user.The
502 GALAXY_HOST_UNREACHABLEresponse has anerror.hintfield 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. Withretryable: false, the build was dispatched and its durable outcome isunknown:recoveryActionpoints to the operation list; do not repeat the deploy blindly. Withoutretryable: 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/datavolume 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 insleepingstatus, 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/wakecall is not needed. If it does not,503 WAKE_TIMEOUTarrives and the server returns tosleeping— 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 with402 ACCOUNT_FROZEN, and for the other causes — for example, expired platform access or an administrative block —403 SERVER_WAKE_BLOCKEDarrives. In both cases a retry will not help until that state changes./exec,/uploadand/logsbehave 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_UNREACHABLEarrives withoutretryable: false, and the host keeps booting — the platform does not roll the wake back; followerror.hintafter the tunnel recovers. If the response carriesretryable: false, the build was dispatched and its outcome isunknown: 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.