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

A fast release cycle

Scope: vibe:infra

A full redeploy via POST /v1/infra/servers/:id/deploy runs nine steps in a row: stop the service, clean, download the archive, install the runtime, install dependencies, write .env, run the preStart commands, create the systemd unit, and start the service with a health check. 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 all nine steps on every small code change is slow. Below are four techniques that shorten the loop: drop the request-body inflation, avoid reinstalling heavy dependencies, update a single changed file instead of the whole tree, and keep data separate from code.

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 takes long Multipart instead of base64 About 33% less volume, merge mode by default
Every deploy reinstalls heavy tools Heavy dependencies outside the install script The install step stops hitting its 300 seconds
One file changed, yet all nine steps run 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 "archive=@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('archive', 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 belong 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 to reinstall what is already on disk.

Targeted updates via /upload

When a single file or a single bundle has changed, a full redeploy 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 other eight deploy steps. 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 the fact of extraction:

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.

Start the restart only after a successful upload: if /upload returned a refusal while systemctl restart has already gone out, 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. An upload of up to 500 MB works for both a whole bundle and a single file.

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 and 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.

The upload ceiling is 500 MB. The /upload route accepts up to 500 MB per file and rejects anything larger.

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