For AI agents: markdown of this page — /docs-content-en/infra/deploy/exec.md documentation index — /llms.txt
Run a command
POST /v1/infra/servers/:id/exec
Runs a shell command on a BLACKHOLE server through the tunnel agent. The full standard output (stdout), error stream (stderr), and return code are passed to the client. By default the response is JSON after the command finishes (recommended for AI agents and scripts). If you need line-by-line progress — pass ?stream=true and read the SSE events stdout/stderr/exit. A failure during command execution arrives in streaming mode as an error event.
For AI agents and MCP clients always use JSON mode (without ?stream=true) — they cannot parse SSE.
Parameters
| Parameter | In | Type | Required | Default | Description |
|---|---|---|---|---|---|
id |
path | string (UUID) | yes | — | BLACKHOLE server ID, status: running, blackholeStatus: CONNECTED |
stream |
query | string | no | false |
true — SSE mode; otherwise — JSON response |
Request fields (body)
| Field | Type | Required | Description |
|---|---|---|---|
command |
string | yes | Shell command. 1–10,000 characters |
timeout |
number | no | Execution timeout in seconds: 1–600. Default 300 |
workdir |
string | no | Working directory. Up to 500 characters |
env |
object | no | Environment variables: { "KEY": "value" }. Only string values |
Examples
curl — personal key
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/exec?stream=false" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"command": "ls -la /opt/app", "timeout": 30}'
curl — OAuth application
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/exec?stream=false" \
-H "X-Api-Key: YOUR_APP_KEY" \
-H "Authorization: Bearer USER_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"command": "npm ci --production", "workdir": "/opt/app", "timeout": 180}'
JavaScript — personal key
const res = await fetch(
`https://vibecode.bitrix24.com/v1/infra/servers/${serverId}/exec?stream=false`,
{
method: 'POST',
headers: {
'X-Api-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
command: 'node -v',
timeout: 10,
}),
}
)
const { data } = await res.json()
console.log(`exit ${data.exitCode} in ${data.duration}s:\n${data.stdout}`)
JavaScript — OAuth application
// A command with environment variables and a working directory
const res = await fetch(
`https://vibecode.bitrix24.com/v1/infra/servers/${serverId}/exec?stream=false`,
{
method: 'POST',
headers: {
'X-Api-Key': 'YOUR_APP_KEY',
'Authorization': 'Bearer USER_SESSION_TOKEN',
'Content-Type': 'application/json',
},
body: JSON.stringify({
command: 'npx tsx scripts/seed.ts',
workdir: '/opt/app',
env: { DATABASE_URL: 'postgresql://localhost/mydb' },
timeout: 60,
}),
}
)
Response fields
| Field | Type | Description |
|---|---|---|
success |
boolean | true if the command ran (including a non-zero exitCode) |
data.exitCode |
number | The process's return code |
data.stdout |
string | The command's standard output |
data.stderr |
string | The command's error stream |
data.duration |
number | Execution time in seconds |
data.truncated |
boolean | true if stdout/stderr were truncated by size (5 MB per stream) |
Response example
{
"success": true,
"data": {
"exitCode": 0,
"stdout": "Linux epd65hdv07p8g89c0c06 6.8.0-107-generic #107-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar 13 19:51:50 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux\n",
"stderr": "",
"duration": 5,
"truncated": false
}
}
Error response example
409 — another operation is already running on the server. This is a failure before the command starts, so the HTTP status reflects the outcome:
{
"success": false,
"error": {
"code": "EXEC_BUSY",
"message": "Another operation is running on this server",
"retryable": true,
"retryAfter": 10
}
}
The response also carries a Retry-After HTTP header (in seconds, equal to the retryAfter field) — this is a short poll interval: keep retrying with it until the lock clears. The full upper bound of "when the lock auto-expires" is in error.hint.autoExpiresInSeconds.
Errors
The endpoint's errors fall into two groups, and they are delivered differently. A failure before the command starts arrives with a real HTTP status. A failure during execution arrives in the response body.
Errors before the command starts
| HTTP | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
The request schema is violated (empty command, invalid timeout, workdir longer than 500 characters) |
| 400 | NOT_BLACKHOLE |
Server in OPEN mode — the Deploy API is unavailable |
| 400 | COMMAND_TOO_LONG |
The command is longer than 10,000 characters. The response carries a hint: ship large payloads and scripts via /upload, then run bash /path/script.sh |
| 401 | MISSING_API_KEY |
The X-Api-Key header was not provided |
| 401 | INVALID_API_KEY |
Invalid or expired API key |
| 404 | NOT_FOUND |
The server does not exist, was deleted, or belongs to another API key |
| 409 | EXEC_BUSY |
Another /exec or /deploy is already running on the server. The response carries a Retry-After header and retryable: true / retryAfter (seconds) fields — retry at that interval. Or release the lock via /lock. If EXEC_BUSY persists after releasing the lock — unstick the channel via POST /v1/infra/servers/:id/unstick (see below) |
| 409 | SERVER_NOT_READY |
The server is not ready for the operation: it is not running or the tunnel is not connected. The response carries a hint field with the reason and the next step. Wake the server or call /repair and repeat the request. The "listed as connected but the Gateway has no live tunnel" case on this route arrives as 502 TUNNEL_NOT_FOUND (see below) |
| 429 | RATE_LIMITED |
The limit of 10 operations per minute per server was exceeded |
Errors during execution
The HTTP status does not reflect the command's outcome. On a separate virtual machine (
kind: "STANDALONE") the connection is held open for the whole run, and the200status is sent before execution begins — so both a successful and a failed command 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 command as successful. For a galaxy app (kind: "GALAXY_APP") the same error arrives with the status502.
| Code | Description |
|---|---|
EXEC_TIMEOUT |
The timeout was exceeded (or the standard 300 seconds). The agent terminates the whole process group forcibly (SIGKILL, no grace period). The response carries a hint object, its fields are described below |
EXEC_FAILED |
An execution error on the agent |
EXEC_BUSY |
The agent's own exec mutex is busy — it is already running another command. This differs from the platform lock (409 before the start): it arrives during execution, in the body as success: false (on STANDALONE — with HTTP 200, on GALAXY_APP — with 502). The response carries a hint with the recovery path via /unstick |
The `hint` fields on `EXEC_TIMEOUT`
| Field | Type | Description |
|---|---|---|
hint.reason |
string | What happened: the command did not finish within the allotted time, and the whole process group was terminated immediately, with no grace period |
hint.recovery |
string | What to do: the upper bound for timeout is 600 seconds, so run longer operations as a background job (systemd-run --unit=<name>) and follow it via GET /v1/infra/servers/:id/logs with the ?service=<name> parameter; for progress within the timeout pass ?stream=true |
hint.recoveryAction |
string | A pointer to the documentation: docs: /docs/infra/deploy/exec |
The hint arrives both in the JSON response and in the SSE error event of streaming mode. An agent-side EXEC_BUSY carries a hint object of the same shape — there recoveryAction points to POST /v1/infra/servers/:id/unstick.
A tunnel communication failure arrives in the same shape — with its own code in the error.code field (for example TUNNEL_NOT_FOUND or GATEWAY_TIMEOUT: …). Some gateway codes carry details after a colon (GATEWAY_UNREACHABLE: …, GATEWAY_TIMEOUT: …), so match error.code by prefix, not by an exact comparison. In streaming mode (?stream=true) the same errors arrive as an SSE error event. The event data carries code and message, and for EXEC_TIMEOUT and an agent-side EXEC_BUSY a hint as well. There is no success field in it — the error event itself is the failure marker.
Full list of common API errors — Errors.
Known specifics
- The command runs through
/bin/sh. Thecommandfield is executed by the/bin/shinterpreter — a minimal POSIX shell (dash), notbash. Constructs specific tobash(set -o pipefail,[[ … ]], arrays) are unavailable in it and fail. Run them explicitly — viacommand: "bash -c 'set -o pipefail; …'"or as a script filecommand: "/bin/bash /opt/app/script.sh". - Server-level locking. While an
/execor/deployis running, a second such call returns 409EXEC_BUSY. If the previous/deploywas interrupted (for example, failed at thehealthcheckstep or the client aborted the connection on a timeout), the server lock remains held until it expires automatically — for/deploythis is up to 15 minutes, for/exec— until the command'stimeoutexpires plus 90 seconds. The lock can be released immediately by callingDELETE /v1/infra/servers/:id/lock, after which/execworks again. There is no need to recreate the server. - The maximum size of
stdout/stderris 5 MB per stream. If the command's output is larger,truncated: truearrives and the tail is cut off. The command finished anyway, andexitCodeis correct. For large output, redirect to a file:command: "my-cmd > /opt/app/output.log 2>&1"and then read it via/exec cat /opt/app/output.log. - The
.envfrom/deployis not picked up automatically. Systemd loads.envwhen the service starts, but not for one-off commands via/exec. If you needDATABASE_URL/ API keys — pass them in theenvfield:{ env: { DATABASE_URL: "..." } }. - The two timeouts together give
timeout + 30seconds. The agent kills the process exactly attimeout; the Gateway waits another 30 seconds for the final events and only then returnsEXEC_TIMEOUT. - Keeping the connection alive in JSON mode. If the command runs longer than 15 seconds, the server periodically sends spaces in the response body — this prevents timeouts in nginx and intermediate proxies. The JSON parser ignores leading and trailing spaces, so the client simply reads valid JSON once the command completes.
- Client timeout: make sure it is inter-byte, not total. The keepalive spaces reset "silence" timeouts, so the classic Python socket timeouts (
urlopen(..., timeout=300), therequestsread timeout) will not by themselves abort a long command in JSON mode. Two other things do abort it. First — clients and wrappers with a true wall-clock deadline for the whole request (for example,aiohttpwithClientTimeout(total=…)or an agent framework's own deadline): keepalive cannot help against those. Second — the server-sidetimeoutof the command itself (300 seconds by default): once it expires, the agent kills the process and you getEXEC_TIMEOUTregardless of the client settings — do not mistake it for a client-side disconnect. In practice: in Python set split timeouts —requests.post(url, json=body, headers=headers, timeout=(10, 60))— 10 seconds for connect and 60 for the inter-byte pause; if the client does drop the connection, the server-side lock persists for up totimeout + 90seconds (EXEC_BUSYfor repeated calls). Commands longer than a few minutes are better run as a background job — see "Background jobs" below.
Unsticking a stuck channel
If /exec still returns EXEC_BUSY after DELETE /v1/infra/servers/:id/lock (or /deploy fails with code DEPLOY_FAILED and the message "Another command is running"), the agent-side exec mutex has leaked — a background process is holding the channel open, so the server lock is released but the channel stays busy. POST /v1/infra/servers/:id/unstick releases the platform-side lock AND bounces the agent tunnel, which on reconnect finishes the stuck command and frees the mutex. The server is not rebooted.
If a legitimate operation (a deploy, exec, or harden) is still running on the server when you call it, the endpoint returns 409 OPERATION_IN_PROGRESS by default and leaves it alone — otherwise it would abort the running command. Only a genuinely stuck channel should be unstuck; if you are certain it is hung, retry with ?force=true.
Response: { "success": true, "data": { "backendLockReleased": true, "agentBounced": true, "reconnected": true } }.
Unsticking errors:
| HTTP | Code | Description |
|---|---|---|
| 404 | SERVER_NOT_FOUND |
The server does not exist, was deleted, or belongs to a different API key. The same code arrives if the server record disappeared between the access check and the unsticking |
| 409 | OPERATION_IN_PROGRESS |
A legitimate operation (a deploy, exec, or harden) is running on the server — the channel is not stuck. The message field names the operation. Retry with ?force=true only if you are certain the channel really is hung |
| 409 | CONFLICT |
Unsticking of this server is already in progress — wait for it to finish |
| 409 | GALAXY_UNSTICK_UNSUPPORTED |
Unsticking is not supported for a galaxy host or a galaxy app: the host is shared, and bouncing its tunnel would abort the commands of neighbouring applications |
| 429 | RATE_LIMITED |
The limit of 6 requests per minute per "API key + server" pair was exceeded |
| 502 | GATEWAY_ERROR |
The platform-side lock was released, but the agent tunnel could not be bounced — the gateway is unreachable. Recovery is incomplete, retry the request |
Choosing the mode
Three call modes and when each one fits:
| When | How | Monitoring |
|---|---|---|
| The command fits into 600 seconds, no progress needed | { "command": "...", "timeout": 600 } without ?stream=true — a blocking JSON response |
exitCode, stdout, stderr in the response body |
The command fits into timeout, live progress needed |
the same request with ?stream=true — SSE events stdout / stderr / exit |
read the events as they arrive |
| The command runs longer than 600 seconds, or the channel must be freed | a background job systemd-run --unit=<name> — /exec returns immediately |
GET /v1/infra/servers/:id/logs?service=<name> |
Do not set the client-side overall request deadline lower than the command's timeout — the client would cut the connection before the server can answer. Split timeouts in Python:
import requests
r = requests.post(
f"{VIBE_URL}/v1/infra/servers/{SERVER_ID}/exec",
headers={"X-Api-Key": VIBE_API_KEY},
json={"command": "npm ci --production", "workdir": "/opt/app", "timeout": 600},
timeout=(10, 60), # 10 seconds to connect, 60 for a pause between bytes
)
Raising the /exec timeout does not help the install step — deploy steps have their own 300-second limit, separate from the 600 of /exec.
⚠️ The timeout default is 300 seconds, not 600. The 600 ceiling exists, but to use it the field must be passed explicitly. A command planned for nine minutes and started without timeout is killed at the fifth.
Background jobs
The /exec timeout is capped at 600 seconds, and when it expires the agent terminates the whole process group forcibly (SIGKILL, no grace period). Run anything longer — package installation, a database dump restore, a heavy build — as a background job: /exec returns instantly, the channel is freed (a second call will not hit EXEC_BUSY), and the process keeps running independently of the HTTP connection.
Recommended — a transient systemd unit
{ "command": "systemctl reset-failed restore-db 2>/dev/null; systemd-run --unit=restore-db /bin/bash /opt/data/restore.sh", "timeout": 30 }
The job gets its own cgroup and lives independently of the exec session. Manage it with standard calls:
- status:
{ "command": "systemctl is-active restore-db" }—active(running),inactive(finished successfully),failed(crashed). - logs: journald picks up the unit's output automatically — read it via
GET /v1/infra/servers/:id/logs?service=restore-db. systemctl reset-failed <name>before a rerun — otherwise systemd refuses to create a unit with the name of the failed job.
Lightweight — background with output redirect
{ "command": "(cd /opt/app && npm run build > /tmp/build.log 2>&1 &)", "timeout": 10 }
Redirecting both streams to a file is mandatory — it releases the exec session's pipes. Progress: { "command": "tail -20 /tmp/build.log" }.
Anti-pattern — bare `nohup`
nohup cmd & without a redirect does not work: in a pipe (rather than a terminal) nohup does not redirect the output, the background process inherits the exec session's pipe, and the agent waits for it to close right up to the timeout — then forcibly terminates the whole process group, including your "background" process. Always add > file 2>&1.
Pass secrets (database passwords and the like) via the env field, not inside command — the agent logs executed commands (the first 200 characters). Also keep in mind that the install/preStart steps of /deploy have their own 300-second timeout — move heavy installations into a one-off /exec or a background job, not into the install script.
See also
- Full deploy —
POST /v1/infra/servers/:id/deploy— a chain of several/execwith a health check. - Release a stuck lock —
DELETE /v1/infra/servers/:id/lock— onEXEC_BUSYafter an abort. - Service logs —
GET /v1/infra/servers/:id/logs— the system journal viajournalctlplus monitoring of background jobs started viasystemd-run(?service=<unit name>). - Upload a file —
POST /v1/infra/servers/:id/upload— write a file to the server. - Repair the tunnel — on
SERVER_NOT_READY. - A fast release cycle — techniques that shorten the edit loop without a full deploy.
Full application deploy
POST /v1/infra/servers/:id/deploy
A full deploy cycle in a single request: stop the previous service → download the archive → extract → install the runtime → install dependencies → write .env → run preStart commands → create the systemd unit → start → health check. Use it instead of chaining /upload + /exec — it is faster and atomic. By default the response is JSON (recommended for AI agents). 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).
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:
runtimeandstartare required. Withoutruntimethe request returns400 GALAXY_DEPLOY_RUNTIME_REQUIRED.- The source is the inline
source.contentonly (a base64 archive). Thesource.urlandsource.versionIdvariants are rejected for a galaxy application with400 GALAXY_DEPLOY_CONTENT_ONLY. - A
Dockerfilein the archive is not required — the platform generates it itself fromruntime,port, andstart. 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 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 | false |
true — SSE mode, otherwise a JSON response |
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 archive with the code. Up to 500 MB |
source.content |
string | yes (or url / versionId) |
— | Base64 string of the archive. Up to 500 MB per request body |
source.versionId |
string | yes (or url / content) |
— | Snapshot ID from the application's source storage, for example v3 — see Source storage |
start |
string | yes | — | Application start command (example: cd /opt/app && node server.js) |
port |
number | no | 3000 |
Port the application listens on. Always 3000 for BLACKHOLE — if not passed, 3000 is substituted. (In multipart mode the port is required, see the table below.) |
extractTo |
string | no | /opt/app |
Extraction directory |
runtime |
string | no | — | Runtime ID: node20, python311, php83, static, etc. For the full list, see GET /v1/infra/runtimes. The server is created from a stock Ubuntu 24.04 image — without runtime it has no node, no python, no php, and start fails with command not found. 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 |
env |
object | no | — | Environment variables { "KEY": "value" } — written to .env and picked up by systemd |
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, it 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") |
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 restored after extraction. 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 |
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" |
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 |
|---|---|---|---|
archive |
file | yes | Archive (tar.gz / zip), up to 500 MB |
start |
string | yes | Start command |
port |
string | yes | Port (as a string: "3000") |
extractTo |
string | no | Defaults to /opt/app |
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 |
preStart |
string | no | Command run before start |
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 |
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 restore it after extraction |
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" |
Deploy steps
Steps run sequentially. If one fails, the deploy stops.
| Step | Condition | What it does | Timeout |
|---|---|---|---|
stop_existing |
always | systemctl stop {serviceName} — stops the service with the current serviceName and frees the port from the remaining processes of the same deploy. An unrelated process is not terminated: the step returns the warning status and the deploy continues |
15s |
clean |
cleanDeploy: true |
Deletes everything in extractTo |
30s |
download |
always | Downloads and extracts the archive (1 retry on error) | 10 min |
cleanup_metadata |
after extract | Removes macOS sidecars (._*, .DS_Store) — silently if the archive is clean |
— |
normalize_windows_paths |
after extract | Normalizes Windows-style \ in ZIP paths from PowerShell Compress-Archive |
— |
debug_info |
debug: true |
Diagnostics: ls -la, sha256sum of files, file -i, locale. Reads up to 32 KB into stdout |
— |
runtime |
runtime passed |
Installs the runtime. On a repeat deploy with the same one — reinstalls it | 300s |
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 |
— |
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 |
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. The status is always warning. stdout carries the reason for the rollback, what to do to keep the hardened mode, and the tail of the service journal |
— |
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 |
~30s |
tunnel_routing |
always | Verifies that the Gateway tunnel routes to the right port (set_port + agent probe). Status warning (not error) — the check did not pass, the deploy is not aborted |
~10s |
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?stream=false" \
-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?stream=false" \
-H "X-Api-Key: YOUR_API_KEY" \
-F "archive=@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?stream=false" \
-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?stream=false" \
-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?stream=false" \
-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 && python -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?stream=false`,
{
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?stream=false`,
{
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: stop_existing, download, runtime, install, env, pre_start, service_user, systemd, start, healthcheck, hardening, tunnel_routing, etc. |
data.steps[].status |
string | ok — success, warning — the step did not do what it intended but the deploy continues (the explanation is in stdout or stderr; this is how stop_existing, service_user, start, hardening and tunnel_routing report themselves), error — failure (only the last step can have this status) |
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 service_user and hardening |
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.source |
object | The result of source auto-save. Only on success: true. In SSE mode it arrives as a separate event: source. 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 |
warnings |
array<string> | Present only when there are warnings — for example, displayName/description were not passed (see "Known specifics"). In SSE mode it arrives in the same event: done |
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. On a separate 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 app (kind: "GALAXY_APP") the same error arrives with the status 502.
Response example
A successful deploy (JSON):
{
"success": true,
"data": {
"steps": [
{ "step": "stop_existing", "status": "ok" },
{ "step": "download", "status": "ok" },
{ "step": "runtime", "status": "ok", "duration": 45 },
{ "step": "install", "status": "ok", "duration": 12 },
{ "step": "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",
"source": { "autoSaved": false, "skippedReason": "deploy from external URL" }
}
}
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 (see "Known specifics"). 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.
SSE stream with ?stream=true:
event: step
data: {"step":"stop_existing","status":"ok"}
event: step
data: {"step":"download","status":"ok"}
event: step
data: {"step":"runtime","status":"ok","duration":45}
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","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. Pass displayName and description when deploying 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" }
]
}
}
Errors
| HTTP | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
The request schema is violated (source or start missing, 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 |
Invalid or expired API key |
| 402 | ACCOUNT_FROZEN |
The Vibecode balance is frozen |
| 402 | BILLING_EXHAUSTED |
The balance is exhausted — top it up |
| 404 | NOT_FOUND |
The server does not exist, was deleted, or belongs to another API key |
| 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. Or release the lock via /lock |
| 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 the Gateway has no live tunnel. The response carries a hint field with the reason and the next step. The platform attempts to restore the tunnel itself. If that did not succeed — wake the server or call /repair and repeat the request |
| 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" |
| 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. For a galaxy application the source is the inline source.content only |
| 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). A galaxy application accepts the inline source.content only (source.url is rejected with 400 GALAXY_DEPLOY_CONTENT_ONLY), so shrinking the archive is the only recovery |
| 413 | PAYLOAD_TOO_LARGE |
The request body exceeded the platform's hard outer limit (500 MB on the base64 body ≈ ~375 MB binary archive) and was rejected at the edge before the handler. Applies to the Vibe-REST /v1/ routes (this deploy, plus upload and create); OpenAI-compatible AI routes return their error in their own envelope. Shrink the body (for standalone you may pass the archive by source.url). Distinct from GALAXY_UPLOAD_TOO_LARGE, which comes from the gateway during a galaxy deploy |
| 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 galaxy host is reachable, but a connection drop during the build interrupted the deploy and its successful completion was not confirmed. This is transient — retry the same request, your app slot and the /data volume are preserved. If the deploy keeps ending with this error, the app is likely failing to start — check the start command, port, dependencies, environment variables, and memory limit |
| 502 | GALAXY_HOST_UNREACHABLE |
The galaxy host is unreachable — the host tunnel is not in CONNECTED status. This is transient, retry the same request in 1–2 minutes — the application and its data are preserved. The response carries error.hint with the diagnosis and the next step — see "Known specifics" |
| 502 | GALAXY_APP_DEPLOY_FAILED |
Failed to deploy the galaxy application — an unexpected deploy failure. Retry the request |
| 429 | RATE_LIMITED |
The limit of 10 operations per minute per server was exceeded |
| 429 | DEPLOY_BACKEND_BUSY |
Too many concurrent deploys that carry the archive in the request body. The response has a Retry-After: 30 header — retry after that time, or pass the archive by link in source.url: link-based uploads have no such cap |
| 200 | DEPLOY_FAILED |
One of the deploy process steps failed. The error.step field indicates which one. data.steps[-1].stderr contains the details |
| 200 | DEPLOY_TIMEOUT |
A deploy step did not finish within the time allotted to it |
| 200 | DEPLOY_CONNECTION_TERMINATED |
The connection to the server was interrupted mid-deploy — the agent or the tunnel dropped. The deploy may be partially applied. Check which step it broke on and repeat the deploy. If the interruption repeats — call /repair and repeat the deploy |
| 200 | DEPLOY_TUNNEL_STALE |
The Gateway has no live tunnel for the server even though the server is listed as connected. Call /repair and repeat the deploy — editing the deploy payload will not help here |
| 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 separate 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 app (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.
Known specifics
The Bitrix24 catalog card's name and description come from the deploy's
displayName/description. Pass both on every deploy — they become the app's name and description on the Bitrix24 catalog card. If you don't, the catalog keeps the server's technical identifier (slug), and the response returnswarnings[]with a hint.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 30 seconds until the agent's port scanner detects the listening port — after that the placeholder disappears automatically. If the agent has no port auto-detection and thewarningpersists longer, 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 500 MB per request body) or insource.url(a link to the archive), and leave only the start command instart(for examplenode server.js). For a large archive, usesource.urlso you do not bloat the request body with base64.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 restores it after extraction. 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.
- Pass
An app that itself calls the Vibe V1 API needs its own key. If the deployed app talks to the Vibe 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 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 tariff 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.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, keep retrying with it; the full upper bound until the lock clears is inerror.hint.autoExpiresInSeconds. You can release the lock immediately with a call toDELETE /v1/infra/servers/:id/lock. 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 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 that is often 300 seconds), the request still keeps running on the server. Symptoms of such a drop: the response breaks off on spaces, 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) — there events arrive line by line as the steps progress, and proxy and SDK timeouts behave more reliably. To release a stuck lock manually —DELETE /lock.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 app process systemd starts runs as the dedicatedvibeappuser. It owns the deploy directory (extractTo,/opt/appby default) and$HOMEpoints there, so writing inside your own directory,npm/pipcaches and data files all work unchanged. What stops working: writing outside your own directory (/var/lib/,/etc/), connecting to a database reachable only through a root-owned socket (MySQLwithauth_socket), and drivingdockerfrom thestartcommand. Ports below 1024 do work — the platform grants the specific permission for the port you declared.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.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, the state of the service, the process holding the port and the command to free the port —fuser -k <port>/tcp. 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. An unrecognized archive is treated as tar.gz. You do not need to pass the type as an explicit field — and there is no field to pass it in.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 ..Large inline archives compete for a slot. A
source.contentlarger than ~10 KB holds the request in memory for the whole deploy, so such requests are limited to a small number of concurrent ones (by default 2 per backend). When you exceed it, a429is returned with a hint to switch tosource.urlorsource.versionId— they do not have this limit. 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 fieldsruntime,port, andstartthe platform builds aDockerfilewith the runtime base image, anEXPOSE <port>line, and aCMDderived fromstart. 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.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 key's language. There are thirteen 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 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 take 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 build was interrupted by a lack of disk on the host 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 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 concrete call to retry,note— a caveat that the host status in the listing can lag behind the real tunnel state. The point of the hint is single: the application slot, its container and its/datavolume are intact, and the same request should be retried in 1–2 minutes. There is no need to delete and recreate the application — a fresh slot lands on the same host and meets the same condition.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 it does not fit,
502 GALAXY_HOST_UNREACHABLEarrives with theerror.hintfield, and the host keeps booting — the platform does not roll the wake back. An identical repeat request joins the boot already under way and waits for it in the same window instead of starting the wake over. So the correct reaction to this response is to repeat the deploy in 1–2 minutes, several times if needed. A host frozen for billing or stopped manually is not woken — a retry will not help there until that state changes.
See also
- Runtimes list —
GET /v1/infra/runtimes— all available IDs. - Run a command — manual commands instead of the automatic chain.
- Service logs —
?service={serviceName}to check the start. - Release a stuck lock — on
EXEC_BUSYafter a drop. - Set the port — if the application does not listen on 3000 (not recommended).
- Source storage