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

Fast release cycle

Scope: vibe:infra

For a dedicated virtual machine with runtime set, a full deploy via POST /v1/infra/servers/:id/deploy begins in the order runtime → stop_existing → clean → download → install: the platform installs the runtime before stopping the service, cleaning the application tree, downloading the archive, and installing dependencies. It then writes .env, runs the preStart commands, creates the systemd unit, starts the service, and checks its health. Without runtime, stop_existing remains the first step on the server. A sleeping server is woken by the deploy itself. This is the right path for the first release and for switching the runtime, but running the full pipeline on every small code change is slow. Below are four techniques that shorten the loop: stop inflating the request body, avoid reinstalling heavy dependencies, update a single changed file instead of the whole tree, and keep data separate from code.

If runtime fails, the deploy does not issue a stop command, clean or replace the application tree, or read or overwrite .env. The runtime script runs as root and may itself change host dependencies, so continuous operation of the previous application version is not guaranteed. After runtime succeeds, later failures keep the previous behavior: the service may already be stopped and the application tree replaced. No new rollback is performed.

What you need

  • A Vibecode API key with the vibe:infra scope
  • A BLACKHOLE server with the application already released — the techniques below shorten a repeat release, not the first one
  • A build archive on your workstation
  • Node.js 18 or newer for the JavaScript examples

In all examples $VIBE_URL is the base address https://vibecode.bitrix24.com, $SERVER_ID is the BLACKHOLE server identifier, and $VIBE_API_KEY is your API key.

Which technique to use when

The techniques are independent — take the one that addresses your delay, not all of them at once.

What slows you down Technique What it gives
The request body inflates and the upload is slow Multipart instead of base64 About 33% smaller payload, merge mode by default
Every deploy reinstalls heavy tools Heavy dependencies outside the install script The install step stops hitting its 300-second ceiling
One file changed, yet the full pipeline runs Targeted updates via /upload An update with no deploy at all
Data sits in /opt/app and gets wiped Data separate from code cleanDeploy stops being dangerous

A full deploy remains the right path for the first release, for switching the runtime and for changing the start command.

The examples below show individual calls and rely on the VIBE_URL, VIBE_API_KEY and SERVER_ID variables declared in the first JavaScript block.

Multipart instead of base64

Inline source.content is a base64 string of the archive, and base64 grows the payload by about 33%: a 30 MB archive travels over the wire as 40 MB. Multipart mode sends the archive as a file, without that overhead. The second difference is that in multipart mode cleanDeploy defaults to "false" (a merge deploy: new files are laid on top of the existing tree), whereas an inline JSON deploy defaults to cleanDeploy: true and wipes /opt/app before unpacking. For iterative edits the merge mode is faster — you do not have to upload the whole tree every time.

cURL

Terminal
tar -czf app.tar.gz -C ./my-app .
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/deploy" \
  -H "X-Api-Key: $VIBE_API_KEY" \
  -F "file=@app.tar.gz" \
  -F "start=cd /opt/app && node server.js" \
  -F "port=3000"

JavaScript

javascript
import { readFile } from 'node:fs/promises'

const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.com'
const VIBE_API_KEY = process.env.VIBE_API_KEY
const SERVER_ID = process.env.SERVER_ID

const form = new FormData()
form.append('file', new Blob([await readFile('app.tar.gz')]), 'app.tar.gz')
form.append('start', 'cd /opt/app && node server.js')
form.append('port', '3000')

const res = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/deploy`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY },
  body: form,
})
const body = await res.json()
if (!body.success) {
  // A deploy step refusal arrives with the DEPLOY_FAILED code, and the step name is in the step field.
  throw new Error(`${body.error?.step ?? 'deploy'}: ${body.error?.message}`)
}

The response lists the steps it went through:

JSON
{
  "success": true,
  "data": {
    "steps": [
      { "step": "stop_existing", "status": "ok", "duration": 320 },
      { "step": "clean", "status": "ok", "duration": 180 },
      { "step": "install", "status": "ok", "duration": 21350 },
      { "step": "start", "status": "ok", "duration": 900 }
    ],
    "serviceName": "app",
    "status": "running",
    "appUrl": "https://app-b7c1e2a4f9d0.vibecode.bitrix24.com"
  }
}

A step has four possible states: running, ok, warning and error. A step with the warning status does not interrupt the deploy — the explanation arrives in that step's stdout.

The port is required in multipart mode and passed as a string ("3000"). To do a clean deploy instead of a merge, add -F "cleanDeploy=true" (in JavaScript — form.append('cleanDeploy', 'true')).

Heavy dependencies outside the install script

The install step has a 300-second timeout, and it runs on every deploy. A long toolchain installation on this step either hits the timeout or steals minutes from every deploy. The virtual machine file system helps avoid this: cleanDeploy wipes only extractTo (/opt/app by default), while installed runtimes, the /usr directory, and /opt/data survive a redeploy. So a system tool is installed once and stays in place.

Install the heavy tool once, then check that it is present with an idempotent line — it runs the install script only when the tool is not there yet:

Terminal
command -v pg_restore >/dev/null || bash /opt/data/install-tools.sh

Put the line in preStart. On the first deploy pg_restore is not found, so the script runs. On later deploys the tool is already in place, the check passes instantly, and the installation is not repeated. This way, no deploy pays the cost of reinstalling what is already on disk.

Targeted updates via /upload

When a single file or a single bundle has changed, a full deploy is excessive. Upload the changed archive via POST /v1/infra/servers/:id/upload with extract: true — the agent unpacks it straight into the working directory, skipping the full deploy pipeline. Then restart the service via /exec:

Terminal
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/upload" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/dist.tar.gz",
    "path": "/opt/app/dist.tar.gz",
    "extract": true,
    "extractTo": "/opt/app"
  }'

curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/exec" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": "systemctl restart app"}'

JavaScript

javascript
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

const uploaded = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/upload`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    url: 'https://example.com/dist.tar.gz',
    path: '/opt/app/dist.tar.gz',
    extract: true,
    extractTo: '/opt/app',
  }),
}).then(r => r.json())
// A blind restart, without this check, brings up the previous build and creates
// the impression that the edit did not apply.
if (!uploaded.success) throw new Error(uploaded.error?.message ?? 'the file was not uploaded')

const restarted = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/exec`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ command: 'systemctl restart app' }),
}).then(r => r.json())
if (!restarted.success) throw new Error(restarted.error?.message ?? 'the service was not restarted')
if (restarted.data.exitCode !== 0) throw new Error(restarted.data.stderr)

The /upload response confirms the path, the size, and whether the file was extracted:

JSON
{ "success": true, "data": { "path": "/opt/app/dist.tar.gz", "size": 4193280, "extracted": true } }

If the previous command on the server is still running, the restart through /exec is rejected:

JSON
{
  "success": false,
  "error": {
    "code": "EXEC_BUSY",
    "message": "Another operation is running on this server",
    "retryable": true,
    "retryAfter": 10,
    "hint": {
      "reason": "A 'deploy' operation currently holds the lock on this server.",
      "recovery": "If the previous operation crashed or its deploy task is stuck (e.g. the backend restarted, or the server was deleted and recreated), force-release the lock and retry.",
      "recoveryAction": "DELETE /v1/infra/servers/:id/lock",
      "autoExpiresInSeconds": 42,
      "note": "The backend lock auto-expires after ~15 minutes. The Black Hole agent also holds its own exec mutex (\u226410 min) that releases when the running command finishes or times out. If force-releasing the backend lock STILL yields EXEC_BUSY, the agent exec mutex has leaked (a detached background process is holding it open) — call POST /v1/infra/servers/:id/unstick to force-release the lock AND bounce the agent tunnel (its reconnect handler group-kills the stuck exec, freeing the mutex) with no VM reboot."
    }
  }
}

The retryable and retryAfter fields are the machine signal: a retry is worthwhile, the pause is in seconds (never more than 10 — it is the poll interval, not the lock lifetime) and also arrives in the Retry-After header. The lock auto-expires in about 15 minutes, and a stuck one is released manually through DELETE /v1/infra/servers/:id/lock.

Trigger the restart only after a successful upload: if /upload returned a refusal but systemctl restart has already been sent, the service comes back on the old code and it looks like "the deploy did not apply".

The service name in the restart command is the one the app is deployed under. The default is app, hence systemctl restart app. Inline content carries a file of up to about 72 MB — enough for both a whole bundle and a single file — and anything larger goes through the url field.

Data never belongs in the install step

The install step (and preStart) runs on every deploy. If you put data loading into it — inserting rows into a database, importing a reference list, applying a seed — that load repeats on every deploy and the rows are duplicated. Load data into /opt/data once, behind an idempotent guard, so a repeat run adds nothing:

Terminal
test -f /opt/data/.seeded || { bash /opt/data/load-seed.sh && touch /opt/data/.seeded; }

The /opt/data directory survives a redeploy, so the .seeded marker file stays in place between deploys. The first deploy loads the data and sets the marker; every following one sees the marker and skips the load. Keep the deploy step commands for installing code and dependencies, and move a one-time data load behind a guard like this.

Known specifics

The default depends on the mode. The cleanDeploy default depends on the mode: an inline JSON deploy uses true and wipes /opt/app before extracting, while multipart uses false and lays files over the existing tree. The two modes are not interchangeable in their consequences.

The install step has a ceiling. The install step runs on every deploy and is capped at 300 seconds. A heavy installation either hits that timeout or costs minutes on every rollout.

/opt/app does not survive a clean deploy. /opt/app lives until the next clean deploy. Data that must survive a rollout belongs in /opt/data, not in the application tree.

/opt/data is written by root, not by the application — until the directory is declared. Every example above runs on the install and preStart steps, that is, as root, and works exactly as written. The application itself is started by systemd under an unprivileged account, and the platform hands that account only extractTo — so the application's own write to /opt/data fails with a permission error. To let the application write there itself, declare the directory in the deploy body:

JSON
{ "dataDirs": ["/opt/data/state"] }

The platform then creates the directory and hands it to the application account on every deploy.

Declare a dedicated subdirectory, not /opt/data itself. Whoever owns a directory can delete or replace any file in it, including files that root put there and that the application cannot even read. Following the examples above, /opt/data holds install-tools.sh and load-seed.sh, which run as root on every deploy — handing the whole of /opt/data to the application would let it swap those scripts out. Give the application its own subdirectory (/opt/data/state) and keep service scripts and password files in /opt/data itself, which you do not declare. What is handed over is the directory itself, not its contents: files root put inside earlier do not change owner. Details are in Full deploy.

The upload ceiling depends on the method. Inline base64 content is accepted by the /upload route up to 96 MB of body, which is about 72 MB of the file itself, and a larger body is refused with 413 INLINE_SOURCE_TOO_LARGE. A file passed through the url field is downloaded by the agent itself — that path has a 500 MB ceiling.

The refusals depend on whose call it is. For a direct /exec call: EXEC_TIMEOUT — the command did not fit into its own timeout, COMMAND_TOO_LONG — the command is longer than 10000 characters, EXEC_BUSY — an operation is already running on the server.

A deploy answers differently. A step that did not fit into its 300 seconds arrives as DEPLOY_FAILED with a step field — "step": "install", for example — and the timeout text sits in message. Branching on EXEC_TIMEOUT after a deploy is pointless — that code never arrives there.

The full list of error codes — Errors.

See also