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

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}
fieldrequiredmeaning
appyesName; containers are hotlane-<app>-v<N>.
imageyesBase image for the baseline. Forks inherit the live container's filesystem, so this only matters cold.
runyesCommand that starts your app, listening on port.
portyesPort the app listens on inside the container. Published only on a random loopback port; the proxy fronts it.
buildnoIncremental build, chained before run on every container boot - it reruns against the fork's warm caches, which is what keeps it fast.
workdirnoWhere source lands in the container. Default /app.
verifynoHooks 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.
ringnoStopped versions kept for rollback (default 5). Older containers and their snapshot images are pruned.
archivenoRegistry ref; the archivist pushes every promoted version as ref:vN, rebuilt from source. Needs docker login on the daemon host.
notifynoWebhook URL for drift and rejection events. Slack and Discord payloads out of the box.
srcmulti-appThe app's source checkout. Required with serve -apps; in single-config mode the config file's directory implies it.
domainmulti-appHost-header routing key and TLS certificate subject on the shared listeners. Bare hostname only.
replaynoTraffic-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

commandwhat it does
hotlane initDetect the app (Node/TS, FastAPI, Flask, Django, Go), write a commented starter config. -force to overwrite.
hotlane serveRun 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 testLike 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 pushDiff 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 statusLive 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 driftCold-boot the clean image, diff behavior against live now. Exit 1 on drift - cron-able.
hotlane mcpServe 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.

routemeaning
GET /-/healthzDaemon liveness. Always open.
GET /-/v1/statusApp, live version, backend, baseline_commit, ring, last fork, archive/drift state.
POST /-/v1/pushBody = raw git diff. Fork, verify, promote. 200 with timings + hook results on promote; 422 with hook results + fork logs on rejection.
POST /-/v1/testBody = 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=NLive container output.
POST /-/v1/drift-checkRun 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 }}

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

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.

eventfires when
drift_detectedLive behavior stopped matching the clean build (includes the exact diff).
drift_healedA later check came back clean.
push_rejectedA fork failed verification (includes the failing hook).
clean_build_failedThe 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": "..."
      }
    }
  }
}
toolwhat it does
hotlane_statusLive version, ring, held forks, drift verdict, baseline commit.
hotlane_pushDeploy the working directory's changes: fork, verify, promote. Returns promoted true/false with hook verdicts and fork logs.
hotlane_testFork and verify, then HOLD: returns the X-Hotlane-Fork header to validate the running fork while users stay on live.
hotlane_promoteFlip traffic to a held fork - byte-identical to what was tested.
hotlane_discardDestroy a held fork.
hotlane_rollbackFlip to a previous kept version, sub-second.
hotlane_driftCold-boot the clean image, diff behavior against live.
hotlane_logsTail 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

← back to hotlane.dev