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, the service may already be stopped, and the platform does not start the previous version on its own. If the deploy aborts before the flatten step completes, the platform tries to put the previous application tree back — if the clean step moved it instead of deleting it and the outcome of the archive delivery is known. The move-back runs over the same server connection and the deploy response does not show it failing, so check extractTo before retrying. When the download outcome is unknown (DOWNLOAD_OUTCOME_UNCONFIRMED, DOWNLOAD_COMMIT_UNCONFIRMED, DOWNLOAD_RELAY_INTERRUPTED, or a connection drop after the archive delivery started), the previous tree stays in <extractTo>.vibe-prev and the next deploy deletes that directory first — copy it out before retrying if you need the previous version. The conditions under which clean deletes the previous contents with no way to bring them back are listed in the deploy step reference. Once flatten completes, the new tree is accepted, and a failure on a later step does not bring the previous tree back.
What you need
- A Vibecode API key with the
vibe:infrascope - 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 |
|---|---|---|
| What slows you downThe request body inflates and the upload is slow | TechniqueMultipart instead of base64 | What it givesAbout 33% smaller payload, merge mode by default |
| What slows you downEvery deploy reinstalls heavy tools | TechniqueHeavy dependencies outside the install script | What it givesThe install step stops hitting its 300-second ceiling |
| What slows you downOne file changed, yet the full pipeline runs | TechniqueTargeted updates via /upload |
What it givesAn update with no deploy at all |
What slows you downData sits in /opt/app and is lost on a clean deploy |
TechniqueData separate from code | What it givescleanDeploy 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 moves the previous contents of /opt/app aside before unpacking; after a successful deploy none of them remain. For iterative edits the merge mode is faster — you do not have to upload the whole tree every time.
cURL
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
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:
{
"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 replaces 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:
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:
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
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:
{ "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:
{
"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), call POST /v1/infra/servers/:id/unstick and retry: it refuses with 409 OPERATION_IN_PROGRESS whenever a lock is still OBSERVED — a live one, or one whose owner cannot be proven dead. That guard covers the PLATFORM lock only. With no lock observed the call proceeds, and it works by bouncing the agent tunnel: the reconnect handler group-kills whatever the agent is running, including a detached background job. That is precisely why it cures a leaked agent mutex, and precisely why it is not safe to fire while your own long command may still be alive. It clears an observed lock on its own only when that lock is an exec mutex that outlived its own TTL. DELETE /v1/infra/servers/:id/lock releases the backend lock UNCONDITIONALLY and is a deliberate SECOND step. An ABSENT error.hint.holder does not authorize it: that read is fail-open, so a missing holder is also what you get when the platform could not read one. Absence buys a normal retry after Retry-After — nothing more. Force-release only after you have confirmed the server is idle INDEPENDENTLY (GET /v1/infra/servers/:id plus the logs); otherwise keep retrying or escalate.",
"recoveryAction": "POST /v1/infra/servers/:id/unstick",
"autoExpiresInSeconds": 42,
"note": "The backend lock auto-expires after ~15 minutes. The Black Hole agent also holds its own synchronous exec mutex (≤10 min), or a durable exec admission gate for the whole job. First read GET /v1/infra/servers/:id/operations?kind=exec and reconcile durable operations. While a durable operation is running, poll its operationId; an unknown outcome needs support and positive proof that the job stopped. POST /v1/infra/servers/:id/unstick does not clear a durable admission gate. Only when no durable job remains active and a leaked synchronous mutex is independently confirmed should you use that guarded recovery. The order lives in hint.recovery: unstick comes BEFORE any unconditional release, never after it."
}
}
}
First read GET /v1/infra/servers/:id/operations?kind=exec and the previous operation outcome. On STANDALONE, a running durable job keeps the execution channel busy after 202 releases the platform lock. Poll its operation; an unknown outcome needs support and independently confirmed full termination. Missing autoExpiresInSeconds does not prove a leaked mutex. Tunnel recovery and lock deletion do not clear durable admission. Galaxy commands run synchronously with a 600-second limit and share the host channel across all applications and deploys; your own operation list may not show another application’s job, so an empty list does not prove the host is idle.
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 for a stuck backend lock that expiry is the path: the guarded POST /v1/infra/servers/:id/unstick answers 409 while a lock is observed and no force is passed, and clears one by itself only when that lock is an exec mutex past its own TTL. The unconditional DELETE /v1/infra/servers/:id/lock cuts the wait only on the replica that HOLDS the lock (the broadcast spares a live foreign deploy); it remains the last step, taken only once the server is independently confirmed idle.
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:
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 replaces the previous contents of /opt/app with the new tree, 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:
{ "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 finish within 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 finish within 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.