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

Terminal
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

Terminal
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

javascript
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

javascript
// 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

JSON
{
  "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:

JSON
{
  "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 the 200 status is sent before execution begins — so both a successful and a failed command arrive with the code 200. The failure marker is the success: false field and the error object in the response body. Check success in the body, not the HTTP status: a client that branches on the status will treat a failed command as successful. For a galaxy app (kind: "GALAXY_APP") the same error arrives with the status 502.

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. The command field is executed by the /bin/sh interpreter — a minimal POSIX shell (dash), not bash. Constructs specific to bash (set -o pipefail, [[ … ]], arrays) are unavailable in it and fail. Run them explicitly — via command: "bash -c 'set -o pipefail; …'" or as a script file command: "/bin/bash /opt/app/script.sh".
  • Server-level locking. While an /exec or /deploy is running, a second such call returns 409 EXEC_BUSY. If the previous /deploy was interrupted (for example, failed at the healthcheck step or the client aborted the connection on a timeout), the server lock remains held until it expires automatically — for /deploy this is up to 15 minutes, for /exec — until the command's timeout expires plus 90 seconds. The lock can be released immediately by calling DELETE /v1/infra/servers/:id/lock, after which /exec works again. There is no need to recreate the server.
  • The maximum size of stdout/stderr is 5 MB per stream. If the command's output is larger, truncated: true arrives and the tail is cut off. The command finished anyway, and exitCode is 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 .env from /deploy is not picked up automatically. Systemd loads .env when the service starts, but not for one-off commands via /exec. If you need DATABASE_URL / API keys — pass them in the env field: { env: { DATABASE_URL: "..." } }.
  • The two timeouts together give timeout + 30 seconds. The agent kills the process exactly at timeout; the Gateway waits another 30 seconds for the final events and only then returns EXEC_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), the requests read 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, aiohttp with ClientTimeout(total=…) or an agent framework's own deadline): keepalive cannot help against those. Second — the server-side timeout of the command itself (300 seconds by default): once it expires, the agent kills the process and you get EXEC_TIMEOUT regardless 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 to timeout + 90 seconds (EXEC_BUSY for 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:

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.

JSON
{ "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

JSON
{ "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 deployPOST /v1/infra/servers/:id/deploy — a chain of several /exec with a health check.
  • Release a stuck lockDELETE /v1/infra/servers/:id/lock — on EXEC_BUSY after an abort.
  • Service logsGET /v1/infra/servers/:id/logs — the system journal via journalctl plus monitoring of background jobs started via systemd-run (?service=<unit name>).
  • Upload a filePOST /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:

  • runtime and start are required. Without runtime the request returns 400 GALAXY_DEPLOY_RUNTIME_REQUIRED.
  • The source is the inline source.content only (a base64 archive). The source.url and source.versionId variants are rejected for a galaxy application with 400 GALAXY_DEPLOY_CONTENT_ONLY.
  • A Dockerfile in the archive is not required — the platform generates it itself from runtime, port, and start. If you put your own Dockerfile in the archive, it is ignored (the platform always generates its own). See "Known specifics" below.
  • The container working directory is /opt/app (the same as extractTo on a regular virtual machine): the code is copied there, so both a relative command (start: "node server.js") and an absolute path (start: "node /opt/app/server.js") work. The /app path also points to /opt/app — it is a symlink kept for backward compatibility. That is, the same start works for both VM and galaxy deploys.

The 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 starthardeninghealthcheck), 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

Terminal
# 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)

Terminal
# Canonical inline deploy: source.content + start is enough.
# The archive type is detected automatically from the signature of the first bytes —
# tar.gz, zip, and tar.bz2 are supported, no separate format field is needed.
B64=$(base64 < app.tar.gz | tr -d '\n')
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/deploy?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

Terminal
# 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

Terminal
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

javascript
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

javascript
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):

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):

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

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 the 200 status is sent before it begins — so both a successful and a failed deploy arrive with the code 200. The failure marker is the success: false field and the error object in the response body. Check success in the body, not the HTTP status: a client that branches on the status will treat a failed deploy as successful. For a galaxy app (kind: "GALAXY_APP") the same error arrives with the status 502.

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

The full list of common API errors — Errors.

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 returns warnings[] with a hint.

  • A warning status on the tunnel_routing step does not mean the deploy failed. After a successful healthcheck, the platform verifies that the Gateway tunnel routes public traffic to the right port (set_port + agent probe). If the port has not been detected yet or the agent runs in fixed-port mode, the step gets status warning (not error) and the deploy is not aborted. The application's public URL may serve the Black Hole placeholder page for about 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 the warning persists longer, call /repair.

  • The application code is passed via source.content, not via start/install/preStart. The command fields (start, install, preStart) are short shell commands with a 5000-character limit each (command in /exec is 10000). They are not meant for file contents: the base64 of a whole archive does not fit there and gets truncated. Put the code itself (tar.gz / zip, including a single file) in source.content (a base64 string of the archive, up to 500 MB per request body) or in source.url (a link to the archive), and leave only the start command in start (for example node server.js). For a large archive, use source.url so 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 with cleanDeploy: true is deleted before the next deploy:

    1. Pass env on every /deploy — it is regenerated automatically (recommended).
    2. preserveEnv: true — the platform reads .env before the clean and restores it after extraction. Useful when the variables are not at hand at redeploy time. An env passed in the same request wins: the file is written anew and the saved copy is discarded. Dedicated virtual machine only (kind: "STANDALONE").
    3. Store .env outside extractTo — for example, /var/lib/myapp/.env. That directory is not touched by cleanDeploy. Specify the explicit path in start.
    4. cleanDeploy: false — an accumulating (merge) deploy, .env survives, but stale files from previous deploys remain along with it.
  • 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 personal vibe_api_ key in the X-Api-Key header. 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 the env parameter (for example env: { "VIBE_API_KEY": "<key>" }); the app reads process.env.VIBE_API_KEY and sends it as X-Api-Key. A 401 (MISSING_API_KEY / INVALID_API_KEY) means the app carries no valid key, not a tariff problem. A personal key talks to Bitrix24 as its owner — keep the server accessPolicy at OWNER_ONLY, otherwise other users would see the key owner's data. If the app is embedded in Bitrix24 as a placement and shows per-user data, that is a different auth flow, see App authorization.

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

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

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

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

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

    Node.js:

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

    Python:

    Python
    import http.server, threading
    
    class Health(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b'ok')
        def log_message(self, *args):
            pass
    
    threading.Thread(
        target=lambda: http.server.HTTPServer(('', 3000), Health).serve_forever(),
        daemon=True,
    ).start()
    # then — the application's main logic
  • A 15-minute lock. A single /deploy blocks /exec and other /deploy calls. If the previous /deploy failed at the healthcheck step or the client dropped the connection on timeout, subsequent /exec and /deploy calls return 409 EXEC_BUSY until the lock expires automatically — that is, up to 15 minutes from the start of the interrupted deploy. The 409 EXEC_BUSY response carries a Retry-After header and retryable: true / retryAfter (seconds) fields — this is a short poll interval, keep retrying with it; the full upper bound until the lock clears is in error.hint.autoExpiresInSeconds. You can release the lock immediately with a call to DELETE /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 runtime install and a large npm install / pip install — really takes several minutes. In JSON mode the server holds the connection, sending HTTP 200 + chunked spaces every 15 s until it is ready (so Content-Length is absent and the response looks "hung"). The 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 /exec and /deploy calls return 409 EXEC_BUSY until the server-side part finishes or the 15-minute lock expires. What to do: (a) in curl — --max-time 690 or higher, (b) in any SDK — set the request timeout to ≥ 690 s for this endpoint, (c) or use ?stream=true (SSE) — 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-get in preStart works without sudo. The agent and the deploy commands run as root — there is no need to write sudo and it will not cause an error.

  • The app itself runs under an unprivileged account. Deploy commands (install, preStart) and /exec run as root, but the app process systemd starts runs as the dedicated vibeapp user. It owns the deploy directory (extractTo, /opt/app by default) and $HOME points there, so writing inside your own directory, npm/pip caches 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 (MySQL with auth_socket), and driving docker from the start command. Ports below 1024 do work — the platform grants the specific permission for the port you declared.

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

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

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

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

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

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

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

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

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

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

  • When you change serviceName between deploys, stop the old service manually. The stop_existing step stops only the service with the current serviceName. If the previous deploy used the default name app and the next one uses dedup, then systemctl stop dedup does not find the previous app.service. The previous service keeps running and holds the app port — 3000 by default. The new service cannot bind to the port and goes into a restart loop. The healthcheck step recognizes this state, including the case where the previous process answers the request with code 200: the deploy fails. The error message names the port number, 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 with fuser -k <port>/tcp, stop the previous service with systemctl stop <old-name>.service via /exec, or restore the previous serviceName value, and then repeat the deploy. One caveat: when the port diagnostics could not run, a response with status 200–399 counts as success and the deploy completes successfully.

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

  • The archive type is detected automatically, there is no separate format field. For source.content the platform reads the signature of the first bytes (PK → zip, 1F 8B → tar.gz, BZ → tar.bz2), for source.url — the path extension. 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-Archive stores paths with a backslash \. Nested files from such an archive may end up in the root of extractTo instead of their folders, and part of the code will not reach the right place. The normalize_windows_paths step brings such paths back to normal, but to avoid the issue, pack the code into tar.gz: tar -czf app.tar.gz -C ./my-app .. If you need zip — pack it with a Unix tool: zip -r app.zip ..

  • Large inline archives compete for a slot. A source.content larger 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, a 429 is returned with a hint to switch to source.url or source.versionId — they do not have this limit. Small inline archives and versionId do not take a slot.

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

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

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

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

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

    Value What happened
    NO_SOURCE The application slot was created, the code was never uploaded
    DEPLOY_INCOMPLETE The code upload did not finish — the build was interrupted or the host was unreachable
    EMPTY_CONTEXT The archive extracted to an empty context — it holds no application files
    BUILD_TIMEOUT The build did not finish within its time limit
    PORT_NOT_PUBLISHED The container did not take port — the application exited right at start
    RUNTIME_CRASH The container built but did not stay alive after start
    RUNTIME_OOM The container ran out of memory after start — it hit the galaxy memory limit
    RESOURCE The build was interrupted by a lack of disk on the host
    HOST_UNAVAILABLE The galaxy host was unreachable during the build
    NO_PACKAGE_JSON An install command was set explicitly, but there is no package.json at the archive root
    MODULE_NOT_FOUND A module was not found — the entry path in start, or a missing dependency
    INSTALL_FAILED Dependency installation failed
    INSTALL_AUTH The package registry returned 401 or 403 — no access to a private package

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

  • The 502 GALAXY_HOST_UNREACHABLE response has an error.hint field with a recovery plan. It is an object of four strings: reason — why the host is unreachable right now, recovery — what to do, recoveryAction — the 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 /data volume 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_UNREACHABLE arrives with the error.hint field, 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