# A fast release cycle

**Scope:** `vibe:infra`

A full redeploy via [`POST /v1/infra/servers/:id/deploy`](/docs/infra/deploy/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

```bash
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:

```bash
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`](/docs/infra/deploy/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`](/docs/infra/deploy/exec):

```bash
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:

```bash
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](/docs/errors).

## See also

- [Full application deploy](/docs/infra/deploy/deploy)
- [Upload a file](/docs/infra/deploy/upload)
- [Run a command](./exec.md)
