kb-deploy
Last updated May 31, 2026
Deploy Docker services to VPS over SSH, plus declarative fleet rollout of the KB Labs platform with config delivery, canary waves, and auto-rollback.
kb-deploy is a standalone Go binary with two delivery models:
- Imperative
run— builds Docker images, pushes them to a registry, and deploys over SSH using docker compose. It detects which services are affected by the latest git commit and deploys only those. This is the section most of this page covers. - Declarative
apply— rolls out the KB Labs platform itself (gateway, rest-api, workflow, state) across a fleet of hosts from a singledeploy.yaml, delivering each host's runtime config and resolving secrets along the way. See Declarative apply.
It also manages stateful infrastructure (databases, caches, queues) as a separate concern from application deployments — infra is never touched by kb-deploy run.
Install
curl -fsSL https://kblabs.ru/kb-deploy/install.sh | shOr download a binary directly from GitHub Releases.
Build from source:
git clone https://github.com/KirillBaranov/kb-labs
cd kb-labs/tools/kb-deploy && make buildQuick start
# Deploy targets affected by the last commit
kb-deploy run
# Deploy all targets regardless of git changes
kb-deploy run --all
# Deploy a specific target
kb-deploy run kb-labs-web
# Bring up all infrastructure services (idempotent)
kb-deploy infra up
# Check what's running (targets + infra)
kb-deploy status
kb-deploy infra statusConfiguration
kb-deploy discovers config by walking up from cwd looking for .kb/deploy.yaml. Pass an explicit path with --config.
Environment variables
All string fields support ${VAR} substitution. Variables are resolved in priority order:
- Process environment — shell env, CI secrets.
.envfile in the repo root — loaded automatically, supports multiline quoted values.
Process env always wins over .env.
deploy.yaml reference
registry: ghcr.io/yourname
# ─── Infrastructure ──────────────────────────────────────────────────────────
# Stateful services managed independently from application targets.
# Never touched by kb-deploy run.
infrastructure:
qdrant:
type: docker-image
image: qdrant/qdrant:latest
ssh:
host: ${SSH_HOST}
user: ${SSH_USER}
key_env: SSH_KEY # name of the env var holding the private key PEM
volumes:
- qdrant_data:/qdrant/storage
ports:
- "6333:6333"
restart: unless-stopped
strategy: manual # "manual" (default) | "diff"
redis:
type: docker-image
image: redis:7-alpine
ssh:
host: ${SSH_HOST}
user: ${SSH_USER}
key_env: SSH_KEY
volumes:
- redis_data:/data
restart: unless-stopped
strategy: manual
# ─── Application targets ─────────────────────────────────────────────────────
# Stateless services deployed by kb-deploy run.
targets:
kb-labs-web:
watch:
- sites/kb-labs-web/** # paths that trigger a deploy when changed
image: kb-labs-web # image name (registry prefix added automatically)
dockerfile: sites/kb-labs-web/apps/web/Dockerfile
context: sites/kb-labs-web
ssh:
host: ${SSH_HOST}
user: ${SSH_USER}
key_env: SSH_KEY
remote:
compose_file: ~/app/docker-compose.yml
service: webInfrastructure strategy
| Strategy | Behavior |
|---|---|
manual (default) | Only kb-deploy infra up/down touches this service. run never does. |
diff | infra up is called during run if the image digest changed. For services you want auto-updated in CI. |
SSH key setup
Store the private key PEM in an environment variable (e.g. SSH_KEY). For CI, add it as a secret. The .env file supports multiline quoted values for local development:
# .env
SSH_HOST=1.2.3.4
SSH_USER=deploy
SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAA...
-----END OPENSSH PRIVATE KEY-----"Never commit .env to git. Add it to .gitignore.
Commands
kb-deploy run
Build, push, and deploy affected or specified targets.
kb-deploy run # affected by last commit (git diff HEAD~1)
kb-deploy run --all # all targets
kb-deploy run kb-labs-web # specific target
kb-deploy run --json # machine-readable outputWhat it does for each target:
docker buildthe image with the current git SHA as tagdocker pushto the registry- SSH into the host,
docker pullthe new image docker compose up -dto restart the service
kb-deploy infra up
Bring up infrastructure services. Idempotent — safe to run repeatedly.
kb-deploy infra up # all infra services
kb-deploy infra up qdrant # specific service
kb-deploy infra up --jsonBehavior per service:
- Already running — no-op
- Stopped —
docker start - Absent —
docker pullthendocker runwith configured volumes, ports, and restart policy
kb-deploy infra down
Stop and remove an infrastructure container.
kb-deploy infra down qdrant
kb-deploy infra down qdrant --jsonThis runs docker stop + docker rm. Data in named volumes is preserved.
kb-deploy infra status
Show runtime state of all infrastructure services.
kb-deploy infra status
kb-deploy infra status --json● qdrant running qdrant/qdrant:latest
● redis running redis:7-alpinekb-deploy status
Show last deployed SHA and timestamp per application target.
kb-deploy status
kb-deploy status --jsonkb-deploy list
List configured application targets.
kb-deploy list
kb-deploy list --jsonJSON contracts
All commands support --json, --agent (the compact variant — drops meta), and --output human|json|agent. Stdout contains exactly one JSON object. Exit code 0 means the command ran (not that all services are healthy). Exit 1 means a tool-level error (config not found, missing env var).
// kb-deploy run --json
{ "ok": true, "sha": "abc1234", "results": [
{ "target": "kb-labs-web", "sha": "abc1234", "ok": true }
]}
// kb-deploy infra up --json
{ "ok": true, "results": [
{ "name": "qdrant", "ok": true },
{ "name": "redis", "ok": true }
]}
// kb-deploy infra status --json
{ "ok": true, "services": [
{ "name": "qdrant", "image": "qdrant/qdrant:latest", "running": true, "state": "running" },
{ "name": "redis", "image": "redis:7-alpine", "running": false, "state": "stopped" }
]}
// error — structured diagnostic envelope (shared by all kb-* launcher tools)
// `error.code` is a stable ERR_-prefixed identifier; `reason` (why) and
// `hint` (what to do / where to look) are present when the tool supplies them.
{ "ok": false, "status": "error", "error": {
"code": "ERR_CONFIG_LOAD",
"message": "deploy config not found: searched up from cwd looking for .kb/deploy.yaml",
"hint": "run kb-deploy from the project root, or pass --config <path>"
}}CI usage
# .github/workflows/deploy.yml
- name: Deploy affected targets
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_KEY: ${{ secrets.SSH_KEY }}
run: kb-deploy run --jsonkb-deploy run exits 0 even when there is nothing to deploy (no affected targets). It exits 1 only when a target fails — CI will catch it.
Declarative apply (ADR-0014)
run is imperative: build this image, push it, restart that container. apply is declarative: you describe the desired fleet state in deploy.yaml, and kb-deploy computes and executes the plan to reach it.
The division of labor:
kb-deploy applyis the fleet orchestrator. It opens SSH to each host, collects current state, computes a rollout plan, delivers config, and drives the waves.kb-create install-serviceis the per-host installer (one host = one installer).applycalls it on each target to install code and swap the active release atomically.kb-devowns service lifecycle and health on the host.
The mechanics below are covered by unit and e2e tests. End-to-end rollout on a live VM is being validated — treat apply as the declarative model and command surface, not yet as a copy-paste production tutorial.
deploy.yaml (declarative block)
schema: kb.deploy/1
platform:
version: 2.94.0
registry: https://registry.npmjs.org
config: kb.config.jsonc # pre-rendered runtime config, delivered verbatim
rollout:
autoRollback: true
parallel: 1 # hosts per wave
lockMode: artifact # "artifact" (default) | "autoCommit"
hosts:
vm-1:
ssh:
host: 203.0.113.10
user: deploy
key_path_env: KBD_SSH_KEY # env var holding a path to the private key
platformPath: ~/kb-platform
env: # per-host secrets/endpoints → host .env
MONGODB_URI: ${secrets.MONGODB_URI}
REDIS_URL: ${secrets.REDIS_URL}
OPENAI_API_KEY: ${secrets.OPENAI_API_KEY}
services:
gateway:
service: "@kb-labs/gateway"
version: 2.94.0
targets:
hosts: [vm-1]
strategy: all # "all" | "canary"
healthGate: 30s
# rest-api, workflow, state … same shapeThe split is deliberate: deploy.yaml is WHERE/HOW (which hosts, which config file, what waves). The rendered kb.config.jsonc is WHAT (adapterOptions: mongo/redis/qdrant URIs, keys). deploy.yaml never carries adapterOptions — that keeps deploy and config authoring separate.
Config delivery
All daemons on a host share one installer-owned <platformPath>/.kb/kb.config.jsonc (the config loader is per-install). apply delivers it plus a resolved per-host .env:
- Pre-rendered, verbatim.
kb-deploydoes not synthesize the config fromdeploy.yaml— you ship a file rendered bykb-createor by hand.${VAR}references in it resolve against the host.env. - Atomic write. Per file: back up the current one to
.prev, stream the new content to a temp file underumask 077, verify it is non-empty, thenmvinto place on the same filesystem. A dropped connection mid-write can never replace a working config with a truncated one. - Secret-safe. Resolved secret values travel over stdin, never argv — they don't leak into
psor shell history. They are never written to the lock or to disk on the control machine. - Config hash.
applyrecordssha256(jsonc + resolved env)per host in the lock. Unchanged hash → no restart. Changed hash with the same release → a config-only restart (so rotating a secret is picked up even when no code changed).
Preflight
Everything that can fail without touching a host fails first, on the control machine, before any SSH is dialed:
- the config file exists and parses as JSONC;
- every
${secrets.X}/${env.X}resolves (a missing or empty value is an error, not a silent blank credential); - no service targets a host that isn't defined.
A broken config or missing secret aborts with zero partial mutation.
Rollout and rollback
Services roll out in waves gated by healthGate. With autoRollback: true, a wave that fails health triggers a rollback: the affected host's config is restored from .prev before the previous release is restarted, so the old release never comes up against new config. Delivery itself runs as a pre-pass across all hosts — if it fails on any host, apply aborts before any install and restores already-written hosts.
Commands
kb-deploy plan # compute the rollout plan, touch nothing
kb-deploy apply --dry-run # preflight + plan, no host mutation
kb-deploy apply # execute (prompts unless --yes)
kb-deploy apply --yes # execute without the confirmation promptExit codes: 0 no changes / dry-run, 1 changes applied, 2 error (validation, SSH, build), 3 rollback fired and succeeded.
Today apply targets single-instance deployments (one replica per service). A config-only change restarts each daemon through the health gate, which is a brief downtime window on one instance. Multi-replica rolling restart is the k8s path — the ConfigDeliverer seam maps the same rendered config → ConfigMap and .env → Secret without changing the planner.
KB Labs integration
Inside KB Labs, kb-deploy is configured via .kb/deploy.yaml in the workspace root. Application targets (web, docs, API) are deployed by CI on every push to main. Infrastructure (Qdrant, Redis) is brought up once with infra up and left running — run never touches it.
Use kb-monitor to observe deployed services and infrastructure from the same config file.
What to read next
- kb-monitor — remote observability for deployed services.
- Source on GitHub — Go source, issues, releases.