documentation
hotlane docs
One binary, one config file, one daemon per host. Everything below is the full surface.
Quickstart
curl -fsSL https://hotlane.dev/install.sh | sh
# or: brew install StefanIancu/hotlane/hotlane | npm i -g hotlane | pip install hotlane
# in your app's repo (Node, Python, or Go)
hotlane init # detects the app, writes hotlane.yml - review it
hotlane serve # boots the warm baseline, proxies traffic on :7480
# make a change, then:
hotlane push
fork hotlane-api-v2 (v2): snapshot 179ms | patch 43ms | boot 743ms | verify 13ms
ok http: /health == 200 (13ms)
PROMOTED v2 live in 978ms
hotlane rollback # flip back, sub-second
Requirements: Docker and git on the daemon host. Start serve from a clean checkout - the daemon snapshots that state as its diff baseline.
The model in six lines
- Baseline: serve boots your app once from source in a container and keeps it warm.
- Push: your git delta is applied to a fork of the running app - warm caches ride along, so no cold rebuild.
- Verify: the fork boots on a private port and must pass your hooks. Unverified forks never receive traffic.
- Flip: promotion swaps a proxy pointer; the previous version parks in a ring.
- Rollback: flip the pointer back to any ring entry - no builds, no pulls.
- Archivist: in the background, every promoted version is rebuilt from source into a real image, pushed to your registry, and behavior-diffed against live to catch drift.
hotlane.yml
app: api
image: node:22-alpine
build: npm install --no-audit --no-fund && npm run build
run: npm start
port: 3000
verify:
- http: /health == 200
timeout: 5s
- run: ./smoke.sh
ring: 5
archive: ghcr.io/acme/api
notify: ${HOTLANE_NOTIFY_URL}
| field | required | meaning |
|---|---|---|
app | yes | Name; containers are hotlane-<app>-v<N>. |
image | yes | Base image for the baseline. Forks inherit the live container's filesystem, so this only matters cold. |
run | yes | Command that starts your app, listening on port. |
port | yes | Port the app listens on inside the container. Published only on a random loopback port; the proxy fronts it. |
build | no | Incremental build, chained before run on every container boot - it reruns against the fork's warm caches, which is what keeps it fast. |
workdir | no | Where source lands in the container. Default /app. |
verify | no | Hooks gating promotion. http: /path == 200 polls until the status appears (a warming app is "not yet", not "failed"), 15s default budget. run: cmd executes in the fork's workdir, 60s default budget, exit 0 passes. Any hook takes an optional timeout: (e.g. 5s, 2m) overriding its default. All hooks run even after one fails. |
ring | no | Stopped versions kept for rollback (default 5). Older containers and their snapshot images are pruned. |
archive | no | Registry ref; the archivist pushes every promoted version as ref:vN, rebuilt from source. Needs docker login on the daemon host. |
notify | no | Webhook URL for drift and rejection events. Slack and Discord payloads out of the box. |
src | multi-app | The app's source checkout. Required with serve -apps; in single-config mode the config file's directory implies it. |
domain | multi-app | Host-header routing key and TLS certificate subject on the shared listeners. Bare hostname only. |
replay | no | Traffic-replay verification: last (buffered requests replayed per push), mode (report/gate), methods, exclude, budget. See shadow testing. |
notify and archive interpolate ${VAR} from the daemon's environment at load, so secrets never live in committed config - an unset variable fails the load loudly. build/run/verify scripts are left untouched: their ${VAR}s belong to the shell inside the container.
Several apps, one box: hotlane serve -apps /etc/hotlane/apps/ serves every *.yml in the directory. Traffic routes by Host header (-tls provisions one Let's Encrypt certificate per domain:); an unknown host gets an explicit 421, never another app's traffic. Every app keeps its own ring, archivist, held forks, and verify gate, the API moves to /-/v1/apps/<app>/ (single-app daemons keep the bare paths as full aliases), and clients pick their app via -app, HOTLANE_APP, or the local hotlane.yml. Startup validation is all-or-nothing: duplicate names, duplicate domains, or a missing src: refuse the whole load with every problem listed.
CLI
| command | what it does |
|---|---|
hotlane init | Detect the app (Node/TS, FastAPI, Flask, Django, Go), write a commented starter config. -force to overwrite. |
hotlane serve | Run the daemon: warm pool + traffic proxy. Flags: -config, -apps DIR (serve every config in a directory), -addr (API, default :7433), -proxy (traffic, default :7480), -token, -tls (built-in HTTPS for every configured domain:; requires a token; -tls-domain D is single-app shorthand). |
hotlane test | Like push, but hold the verified fork instead of promoting: two instances run side by side, users on live, you alone on the fork via the X-Hotlane-Fork: <version> header. Then promote or discard. Held forks expire after 15m (max 3 held). |
hotlane promote <n> | Flip traffic to a held fork - byte-identical to what you tested; verify hooks re-run as a gate. |
hotlane discard <n> | Destroy a held fork; live traffic never knew. |
hotlane push | Diff from the daemon's baseline commit to your working tree, fork, verify, promote. -from <ref> overrides the base. Prints phase timings; exits non-zero on rejection with the fork's logs. |
hotlane rollback [n] | Flip to the previous version, or to version n. Restarts a stopped ring entry if needed. |
hotlane status | Live version, ring with container states, drift verdict, last fork timings. -all lists every app on a multi-app daemon. |
hotlane logs [-n N] | Tail the live version's output through the daemon. |
hotlane drift | Cold-boot the clean image, diff behavior against live now. Exit 1 on drift - cron-able. |
hotlane mcp | Serve hotlane as MCP tools over stdio: status, push, test, promote, discard, rollback, drift, logs - agents get hotlane natively. Every command above also accepts -json. |
Client environment: HOTLANE_DAEMON (default http://127.0.0.1:7433), HOTLANE_TOKEN (sent as a bearer token), and HOTLANE_APP (app name on a multi-app daemon; the -app flag wins, ./hotlane.yml is the fallback).
HTTP API
GET /-/v1 returns a self-describing route index, and the complete one-fetch operating contract lives at llms-full.txt. Everything the CLI does goes through this surface - agents and scripts can use it directly. With -token set, all routes except healthz require Authorization: Bearer <token>. With -tls-domain, :443 is shared the way humans expect: your app owns https://domain/ and the API lives under the reserved /-/ prefix; on the private API port the bare paths below work as aliases.
| route | meaning |
|---|---|
GET /-/healthz | Daemon liveness. Always open. |
GET /-/v1/status | App, live version, backend, baseline_commit, ring, last fork, archive/drift state. |
POST /-/v1/push | Body = raw git diff. Fork, verify, promote. 200 with timings + hook results on promote; 422 with hook results + fork logs on rejection. |
POST /-/v1/test | Body = raw git diff. Fork + verify, then hold: response carries the fork version, TTL, and the header to reach it. |
POST /-/v1/promote | {"version": N} - flip traffic to a held fork (verify re-runs first). |
POST /-/v1/discard | {"version": N} - destroy a held fork. |
POST /-/v1/rollback | {"version": N}, or empty body for the previous version. |
GET /-/v1/logs?tail=N | Live container output. |
POST /-/v1/drift-check | Run the drift check now; returns the verdict. |
CI integration
Your pipeline keeps tests and review; the deploy job becomes hotlane push. The daemon records the baseline commit, so a CI runner's clean checkout deploys correctly: push diffs baseline→HEAD.
On the server, give the daemon a domain and a token - it does its own TLS (Let's Encrypt, auto-renewed), serves your app at https://domain/, tucks the API under /-/, and redirects port 80:
hotlane serve -tls-domain deploy.example.com -token $(openssl rand -hex 24)
Then the deploy job is two secrets and one command - no tunnels, no VPN, no extra infrastructure:
# GitHub Actions deploy job - full recipes in docs/ci.md
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # the diff base must exist locally
- uses: StefanIancu/hotlane-action@v1 # marketplace: install + push + verdict as outputs
with:
daemon: ${{ vars.HOTLANE_DAEMON }}
token: ${{ secrets.HOTLANE_TOKEN }}
- Trust model: TLS for the transport, bearer token for identity - same as any SaaS API.
-tls-domainrefuses to start without a token; tokens are compared in constant time; only healthz is open. App traffic onhttps://domain/is untouched by any of this - it is your public app. - Already have a private network or reverse proxy? Use it instead: point
HOTLANE_DAEMONat the private IP, or front :7433 with your Traefik/Caddy. An SSH tunnel also works for a box with no domain. - History: shallow clones fail loudly - use
fetch-depth: 0orgit fetch --unshallow. - Serialization: one deploy at a time per app (
concurrency/resource_group). - On the box: run the daemon under systemd - a ready unit file ships in packaging/systemd/ (state lands in
/var/lib/hotlanewhen$HOMEis unset; guide). - Full worked examples for GitHub Actions, GitLab CI, and AWS CodeBuild - plus a plain-curl recipe for everything else - live in docs/ci.md.
Shadow testing (traffic replay)
Verify hooks are guesses about what matters; your live traffic is the truth. With a replay: block, the daemon records a rolling in-memory slice of real requests - and the responses live actually served - and replays it against every fork before promotion, diffing the answers. Live gets zero extra load; the fork gets interrogated by your app's own recent users.
replay:
last: 200 # newest buffered requests replayed against each fork
mode: report # annotate the push; "gate" rejects a mismatch like a failing hook
$ hotlane push
ok http: /health == 200 (13ms)
replay 199/200 matched (2 dynamic, status-only) (312ms, 512 buffered)
MISMATCH GET /api/items: live answered 200, fork answers 500
push REJECTED after 1430ms: fork destroyed, live version untouched
- No false alarms by design: bodies are normalized (timestamps, UUIDs, request ids masked), and any path whose answers varied between two live requests compares status only - dynamic content cannot testify against the fork.
- Reads-only by default (GET/HEAD): the fork's state is isolated, but its external side effects - your payment provider, your SMTP - are not. Opting write methods in is deliberate and documented as the loaded gun it is.
- Memory-only buffer: real user data never touches disk and dies with the process.
reportvsgate: an intended content change mismatches by definition, so start with report; graduate an app to gate once report stays quiet.hotlane testholds always carry the report and are never gated - the agent reads the evidence and decides.
The archivist
The warm fork chain is a cache; the archivist is its validation. After every promote it rebuilds your app from source, from scratch, in the background (the image classical CI would have made - minus the waiting), pushes it to archive: if set, and cold-boots it to diff behavior against live: verify hooks must pass, and every http: hook path must serve equivalent responses. The comparison tolerates dynamic content - timestamps, UUIDs, request ids and epoch numbers are masked, and a path that differs between two requests to the same instance compares status only - so a /health reporting uptime doesn't cry wolf.
$ hotlane drift
DRIFTED: behavior differs on /: clean build serves "hello", live serves "TAMPERED"
next push will rebuild from hotlane-api:clean
While flagged drifted, the next push forks from the clean image instead of the warm chain - one ordinary deploy heals production back to a provably-from-source state. Checks run after every archive build, every 6 hours, and on demand.
Notifications
Set notify: to a webhook URL and the daemon speaks up when nobody is watching a terminal. Events fire on transitions, not repeats - a broken state pings once when it breaks and once when it heals.
| event | fires when |
|---|---|
drift_detected | Live behavior stopped matching the clean build (includes the exact diff). |
drift_healed | A later check came back clean. |
push_rejected | A fork failed verification (includes the failing hook). |
clean_build_failed | The archivist cannot reproduce the app from source. |
Payloads carry text (Slack), content (Discord), and structured fields (app, event, detail, at) for anything custom.
Agents & MCP
hotlane is built to be operated by AI agents. Three surfaces, in order of preference:
1. MCP server (native tools)
hotlane mcp speaks the Model Context Protocol over stdio - an MCP-capable agent gets hotlane as typed tools with schemas: no shell, no output parsing, no docs required. Run it from the app's repo (push/test compute the git diff from the working directory):
{
"mcpServers": {
"hotlane": {
"command": "hotlane",
"args": ["mcp"],
"env": {
"HOTLANE_DAEMON": "https://yourapp.example.com",
"HOTLANE_TOKEN": "..."
}
}
}
}
| tool | what it does |
|---|---|
hotlane_status | Live version, ring, held forks, drift verdict, baseline commit. |
hotlane_push | Deploy the working directory's changes: fork, verify, promote. Returns promoted true/false with hook verdicts and fork logs. |
hotlane_test | Fork and verify, then HOLD: returns the X-Hotlane-Fork header to validate the running fork while users stay on live. |
hotlane_promote | Flip traffic to a held fork - byte-identical to what was tested. |
hotlane_discard | Destroy a held fork. |
hotlane_rollback | Flip to a previous kept version, sub-second. |
hotlane_drift | Cold-boot the clean image, diff behavior against live. |
hotlane_logs | Tail the live version's output. |
2. The complete contract in one fetch
hotlane.dev/llms-full.txt is the full operating contract - every command and flag, every endpoint with request/response shapes, the invariants (versions are never reused; diffs run from the daemon's baseline commit), an error catalog with remedies, and the recommended agent workflow. Reading it once is sufficient to operate everything without trial and error. The shorter llms.txt links to it.
3. CLI and HTTP, machine-readable
Every state-touching CLI command accepts -json (the daemon's raw JSON; exit codes preserved - a rejected push is still exit 1). The API self-describes at GET /-/v1. The recommended loop: test → validate your own change against the held fork → promote the exact instance you validated → rollback if you were wrong anyway.
Where does hotlane run?
On a machine you own that runs Docker: a VPS, a bare-metal box, an EC2 instance, a homelab node. One daemon per host, next to your app.
Not on ECS, Fargate, Cloud Run, or Kubernetes - and not inside your app's image. hotlane works by commanding the host's Docker daemon: forking live containers, keeping a ring of stopped versions, fronting them with its own proxy. Managed platforms own that layer themselves, and their orchestrator would fight hotlane over the same containers. hotlane is an alternative to that layer, not a passenger on it: on AWS, the hotlane equivalent of an ECS service is one EC2 instance running hotlane serve.
Keeping your managed platform for prod? Split the loops: run hotlane on a cheap box for dev/staging/agent iteration (push-to-verified in about a second), and let the archivist's registry-pushed, from-source images feed your existing prod pipeline - every version it archives is a normal image ECS or Kubernetes can deploy directly.
Limits & honesty
- State doesn't fork. Forks share your real database. Run migrations expansively (add, migrate, then remove), and treat schema changes with the respect they deserve.
- Drift checks cover hook paths. An endpoint without a hook can drift undetected - add hooks for what matters. Dynamic responses (timestamps) on hook paths will false-positive.
- Single host. One daemon, one box. That covers a huge share of real apps; multi-host is future work.
- Isolation is container-grade. Fine for your own code and trusted teams; not a sandbox for hostile code.
- New files need
git add -Nto appear in a dirty-worktree diff; committed files always work.