@enixcode/light-run
v0.7.0
Published
Run a Docker container from an HTTP request. Thin wrapper around light-runner.
Downloads
72
Readme
Experimental - do not use in production. APIs, defaults and on-disk layout can still change without notice. Pin a commit SHA if you depend on it today.
Ecosystem
light-run is the HTTP layer in a family of small, composable tools.
| Project | Role | Status |
| --------------- | ------------------------------------------------------------ | ------------- |
| light-runner | Docker execution SDK - one container, exit code, files | released |
| light-run | HTTP wrapper around light-runner | this repo |
| light-process | DAG orchestration on top of light-run | released |
Use light-runner when you already have a folder on disk. Use light-run when you want to post files + an image + a command over HTTP.
Install
npm install -g @enixcode/light-run
# or
npm install @enixcode/light-run # use as a libraryPublished under the
@enixcodenpm scope because the unscoped name collides with an unrelated existing package. The CLI binary stayslight-runand the GitHub repo staysenixCode/light-run.
Requirements
- Node.js >= 24
- A running Docker daemon on the host (Docker Desktop,
dockerd, Lima, OrbStack, ...)
Quick start
1. Start the server
light-run serve --token $(openssl rand -hex 32)Or as a library:
import { createServer } from '@enixcode/light-run';
const server = await createServer({
token: process.env.LIGHT_RUN_TOKEN,
logger: true,
});
await server.listen({ port: 3000, host: '0.0.0.0' });Or with Docker Compose (dev):
cp .env.example .env # set LIGHT_RUN_TOKEN if you want auth
npm run dev # = docker compose up --build
# -> server on http://localhost:3001The compose file mounts the host Docker socket so light-runner can spawn
workload containers as siblings on the host daemon, and bind-mounts
./.artifacts so extracted files are inspectable from the host.
2. Post a run
curl -X POST http://localhost:3000/run \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"image": "alpine:3.19",
"entrypoint": "sh main.sh",
"files": { "main.sh": "echo hello > /app/out.txt" },
"extract": ["/app/out.txt"],
"networks": ["none"],
"timeout": 30000
}'You get back the final run state once the container exits:
{
"id": "light-runner-3f9c2a1b4d5e",
"status": "succeeded",
"startedAt": "2026-04-20T10:00:00.000Z",
"finishedAt": "2026-04-20T10:00:03.421Z",
"exitCode": 0,
"durationMs": 3421,
"artifacts": [
{ "path": "out.txt", "bytes": 6, "type": "file" }
]
}Pass "detached": true to get 202 Accepted with an id immediately, then poll GET /runs/:id or receive a signed callback on callbackUrl.
3. Download artifacts
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/runs/$ID/artifacts/out.txtAPI
All endpoints except /health require Authorization: Bearer <token> when the server is started with a token. Without a token, every route is open (the CLI prints a warning at startup).
| Method | Path | Description |
| ------ | ------------------------------- | ---------------------------------------------------------- |
| GET | /health | Liveness + Docker daemon reachability (no auth). 503 if Docker is down. |
| POST | /run | Start a run. Sync by default, detached: true returns 202.|
| GET | /runs | List tracked runs |
| GET | /runs/:id | Full state of one run |
| POST | /runs/:id/cancel | Cancel a running execution (SIGKILL) |
| POST | /runs/:id/stop | Graceful stop: SIGTERM then SIGKILL. Body { signal?, grace? } |
| POST | /runs/:id/pause | Pause (freeze) a running container |
| POST | /runs/:id/resume | Resume a paused container |
| DELETE | /runs/:id | Remove a terminal run + its artifact folder |
| GET | /runs/:id/artifacts | List files extracted from the run |
| GET | /runs/:id/artifacts/* | Download a file (or list a subdirectory) |
| POST | /networks | Create a Docker network. Body { name, driver?, iccEnabled?, exclusive?, labels?, ipam? }. 201 { name, created } |
| GET | /networks/:name | Network existence. 200 { name, exists } |
| DELETE | /networks/:name | Delete a network. 204, or 409 if it still has active endpoints |
| POST | /networks/cleanup | Remove orphan light-runner-* networks. Body { prefix?, maxAgeMs? }. 200 { removed } |
Request body
POST /run accepts a JSON body validated by Zod (src/schemas.ts).
{
image: string; // Docker image reference (required)
files: Record<string, string>; // relative path -> text content (required, >= 1 entry)
entrypoint?: string; // shell command, executed via "sh -c"
run?: string[]; // build-time RUN steps baked into a cached image
input?: unknown; // JSON piped to stdin (sync runs only)
timeout?: number; // ms, max 60 * 60 * 1000
networks?: string[]; // first is primary (NetworkMode), rest connected after; ["none"], ["my-net"], ...
workdir?: string; // working directory inside the container
env?: Record<string, string>; // env vars (name must match [A-Za-z_][A-Za-z0-9_]*)
extract?: string[]; // container paths to pull back after exit
detached?: boolean; // if true, respond 202 and run in background
callbackUrl?: string; // detached only: POSTed final RunState
callbackSecret?: string; // detached only: HMAC-SHA256 signs callback body
}File paths in files:
- must be relative (no leading
/) - cannot contain
..segments - max 1024 characters
extract paths are container-absolute (e.g. /app/out.txt). Extracted files land in an internal artifact directory and are served via GET /runs/:id/artifacts/* - clients never specify a host destination.
Detached + callback
curl -X POST http://localhost:3000/run \
-H "Authorization: Bearer $TOKEN" \
-d '{
"image": "alpine:3.19",
"entrypoint": "echo done",
"files": { "x": "" },
"detached": true,
"callbackUrl": "https://my-app.example.com/hook",
"callbackSecret": "a-secret-of-16-chars-or-more"
}'When the run finishes, light-run POSTs the final RunState as JSON to callbackUrl with header X-Light-Run-Signature: sha256=<hex>, where <hex> = HMAC_SHA256(secret, rawBody).
CLI
light-run - HTTP wrapper around light-runner
Usage:
light-run serve [options]
Options:
--port <n> Listen port (default 3000, env LIGHT_RUN_PORT)
--host <h> Listen host (default 127.0.0.1, env LIGHT_RUN_HOST)
--token <t> Bearer token required on every route except /health
(env LIGHT_RUN_TOKEN; omit to leave open)
--body-limit <n> Max POST body size in bytes (default 10485760 = 10 MiB,
env LIGHT_RUN_BODY_LIMIT). Each request is parsed in
memory, so a big cap is a memory-per-request cost.
--version, -v Print the version and exit
--help, -h Show this messageShared types with light-runner
light-run is a thin HTTP boundary over light-runner - the two packages share several field shapes (image, timeout, networks, env, workdir, input, extract semantics). Rather than redefine everything, light-run re-exports the 1:1 types directly from light-runner:
import type { Runtime, RunnerOptions, ExtractResult } from '@enixcode/light-run';
// identical to `import type { ... } from 'light-runner'`The Zod schema for RunRequest cannot literally inherit from a TypeScript interface (Zod lives at runtime, interfaces at compile time), so the shared fields are duplicated structurally. A compile-time alignment check in src/schemas.ts fails the build if light-runner ever widens or tightens any of those shared shapes - drift is caught, never silent.
Security
light-run sits on top of light-runner, which means every run inherits the hardened defaults of that SDK (dropped capabilities, no-new-privileges, pids / memory / CPU caps, isolated network). See the light-runner security model.
On top of that, the HTTP layer adds:
- Bearer token with timing-safe comparison on every route except
/health. Set via--tokenorLIGHT_RUN_TOKEN. Starting without a token prints a warning. - Body validation via Zod on every request - no free-form fields reach the runner.
- File-map validation: relative paths only, no
..segments, max 1024 chars each. - Path-traversal guard on
GET /runs/:id/artifacts/*: literal..rejected, and the resolved host path is asserted to stay inside the run's artifact directory. - Body limit: 10 MiB default. Configurable three ways:
createServer({ bodyLimit })for library use,--body-limit <bytes>on the CLI, orLIGHT_RUN_BODY_LIMITenv var. This is alight-runcap -light-runnerreads from disk and does not see the HTTP body.
Terminate TLS at a reverse proxy
Do not expose light-run directly on the public internet. Run it behind Caddy, nginx, Traefik, or a managed TLS terminator.
What it does not cover
No rate limiting, no concurrency cap, no repo fetch. Kernel exploits, runc CVEs, side-channel attacks are out of scope - for genuinely hostile code, configure light-runner with a safer runtime (e.g. gVisor).
No request/result caching, no content-addressable file store, no memoization. light-run is stateless past the live artifact directory - deduplication and workflow memory live in light-process, one layer up.
Storage
Artifacts are kept under ~/.light-run/artifacts/<run-id>/ on the host, where <run-id> is the light-runner container name (e.g. light-runner-3f9c2a1b4d5e). Temporary working directories under os.tmpdir() are cleaned as soon as the container exits.
Run state is persisted by light-runner in its state directory (LIGHT_RUNNER_STATE_DIR, default ~/.light-runner/state, one JSON per run). light-run is a stateless projection over it: GET /runs and GET /runs/:id read listStates / readState, so a server restart no longer forgets runs. Fields like status, exitCode, durationMs, and artifacts are recovered from disk. The logs array is in-memory only and is lost on restart (use the run's artifacts for persistent output). A periodic maintenance sweep (see below) reconciles runs left running by a crashed process to failed and garbage-collects old terminal state files. Lifecycle controls (cancel / stop / pause / resume) on a still-running detached run survive a restart by re-attaching to the container (DockerRunner.attach).
Auto-eviction
After every finished run, light-run scans the artifact root. If the total size exceeds the cap, the oldest run directories (by creation time) are removed until the total is back under the cap. Running runs and the run that just finished are never evicted - the client may still be about to download them. When a directory is evicted, the matching run state file is also dropped (subsequent GET /runs/:id returns 404).
Periodic maintenance
Per-run resources are freed the instant a run ends (the container and its volume by light-runner, the tmpdir by light-run). A periodic sweep reclaims what crashes, TTLs and size caps leave behind. light-run serve runs it on boot, hourly, and at shutdown; library embedders call runMaintenance() on their own schedule. Every step is best-effort and isolated, so one daemon hiccup never stops the others, and the reclaim policies (ages, TTLs, byte budgets) live in light-runner:
- States - runs left
runningby a crashed process are reconciled tofailed; terminal state files past the size budget are evicted. - Containers + volumes - orphans left by a crashed host are reaped (
reapOrphans, default older than 5 min). - Cache images -
run[]-derived build-cache images are evicted (default 7 days since last use). - Networks - run-scoped light-process networks (
lp-*) with no attached container are removed (default older than 30 min). - Dangling images - untagged
<none>image layers left by re-pulls and rebuilds are pruned. Tagged base images are never touched.
Environment variables
| Variable | Default | Purpose |
| ---------------------------------- | -------------------------------- | ---------------------------------------------------------------- |
| LIGHT_RUN_ARTIFACTS_DIR | ~/.light-run/artifacts | Override where artifact directories are stored. |
| LIGHT_RUNNER_STATE_DIR | ~/.light-runner/state | light-runner's run-state directory. light-run reads it as its source of truth for GET /runs; restart recovery and GC operate on it. |
| LIGHT_RUN_MAX_ARTIFACTS_BYTES | 21474836480 (20 GiB) | Total bytes across all run artifact dirs before auto-eviction kicks in. |
| LIGHT_RUN_BODY_LIMIT | 10485760 (10 MiB) | Max POST body size (CLI). Each request is parsed in memory. |
| LIGHT_RUN_TOKEN | unset | Bearer token required on every route except /health. |
| LIGHT_RUN_PORT | 3000 | Listen port (CLI). |
| LIGHT_RUN_HOST | 127.0.0.1 | Listen host (CLI). |
| OTEL_EXPORTER_OTLP_ENDPOINT | unset | If set, enables OpenTelemetry. Sends traces + metrics via OTLP/HTTP to this URL (e.g. http://localhost:4318). |
| OTEL_SERVICE_NAME | light-run | Service name attached to every emitted span/metric. |
| LIGHT_RUN_OTEL_DEBUG | unset | Set to 1 to enable OpenTelemetry with a ConsoleSpanExporter instead of OTLP. Spans are printed to stdout (no metrics exported). Useful for local smoke tests without a Collector. |
| LIGHT_RUN_VERSION | package.json version | Overrides the service.version attached to every span/metric. Defaults to the build's package.json version. |
Unset LIGHT_RUN_TOKEN leaves the server open (the CLI prints a warning). Unset or invalid LIGHT_RUN_MAX_ARTIFACTS_BYTES falls back to the 20 GiB default. Explicit DELETE /runs/:id also removes the artifact directory immediately.
Observability (OpenTelemetry)
light-run ships with OpenTelemetry support out of the box. By default the SDK is constructed but never started, so there is zero runtime cost when you do not want tracing. Setting OTEL_EXPORTER_OTLP_ENDPOINT (or LIGHT_RUN_OTEL_DEBUG=1) turns it on.
What you get
- HTTP server spans for every incoming request (
GET /health,POST /run,GET /runs/:id/artifacts/*, ...) via@fastify/otel(the official Fastify-team plugin). Route name, method, status code, and exceptions are recorded. docker.run+ sub-spans emitted bylight-runnerwhenever a container is spawned. The trace context (W3Ctraceparent) propagates automatically so the docker spans nest under the originating HTTP server span.- Auto-instrumentations for
node:httpoutbound calls, DNS, undici/fetch, and a few others via@opentelemetry/auto-instrumentations-node. The noisyinstrumentation-fsis disabled. - OTLP metrics exported on the same endpoint when
OTEL_EXPORTER_OTLP_ENDPOINTis set.
Local smoke test (no Collector needed)
LIGHT_RUN_OTEL_DEBUG=1 light-run serve --port 3001 --token dev
curl -H "Authorization: Bearer dev" http://127.0.0.1:3001/runs
# stdout prints a span tree: dns.lookup, GET /runs, handler - fastify -> @fastify/otel, ...Production setup with an OTel Collector
export OTEL_EXPORTER_OTLP_ENDPOINT=http://collector.internal:4318
export OTEL_SERVICE_NAME=light-run-prod
light-run serve --port 3001 --token "$LIGHT_RUN_TOKEN"The Collector receives traces + metrics over HTTP/protobuf on port 4318 and forwards to your backend of choice (Tempo, Jaeger, Honeycomb, Grafana Cloud, SigNoz, Datadog, etc.).
ESM caveat
For full canonical setup including monkey-patching of node:http, launch with the ESM loader:
node \
--experimental-loader=@opentelemetry/instrumentation/hook.mjs \
--import ./dist/src/instrumentation.js \
./dist/src/bin/light-run.js serveWithout the loader, @fastify/otel still works (it is a Fastify plugin, no monkey-patching needed) so server-side spans are emitted correctly. Outbound HTTP from light-run (e.g. the detached callback) will not be auto-traced.
Docs
- Landing + guides + API reference: enixcode.github.io/light-run, a Fumadocs (Next.js static export) site under
website/, sharing thelight-landing-pagecomponent with the rest of the ecosystem. - Local build:
npm run docs:api # regenerate the API reference into website/content/docs/api npm run docs:dev # docs:api + the Fumadocs dev server npm run docs:build # docs:api + the static export (website/out) - API reference is generated from
src/index.tsby TypeDoc (markdown) and post-processed for Fumadocs byscripts/gen-fumadocs-api.mjs. The landing content lives inwebsite/src/app/(home)/data.tsx; the guides inwebsite/content/docs/. - Pre-commit hook:
npm run setup:hooksinstallsscripts/hooks/pre-commit, which regenerates and re-stageswebsite/content/docs/api/whenever staged files include something undersrc/. - Deploy:
.github/workflows/docs.ymlbuildswebsite/and publishes to GitHub Pages on every push tomain. The Pages source must be set to "GitHub Actions" in the repo settings.
Testing
npm test # clean + build + node --test (60 e2e tests, skipped if Docker daemon absent)
npm run test:docker # same inside a container with the host Docker socket mountedTests are split across six files, all using Fastify's inject() with real light-runner containers against the host Docker daemon:
test/e2e/server.test.ts(13 tests) - core surface: auth, sync + detached runs, artifacts, cancel, delete, list, storage auto-eviction.test/e2e/languages.test.ts(8 tests) - Python / Node / shell real workloads: stdin + JSON compute, multi-file project with local import,crypto.createHashdeterminism, env vars, build-timerunstep, nested directory extraction, multi-MB binary streaming, unicode round-trip.test/e2e/adversarial.test.ts(17 tests) - failure paths: malformed/wrong/empty Bearer, Zod rejects (absolute path,.., empty files, invalid env name, oversize image/entrypoint),413 Payload Too Largeon body-limit breach,..artifact traversal, timeout kills asleep 60in <10 s,network: 'none'actually blocks outbound, shell metacharacters in env values passed literally (no command injection).test/e2e/lifecycle.test.ts(9 tests) - run lifecycle: realGET /healthDocker ping, pause then resume then stop on a live detached run,404on unknown or finished runs,400on invalid stop body,401without Bearer.test/e2e/networks.test.ts(8 tests) - Docker network CRUD: create then exists then delete round-trip, idempotent create + delete, IPAM subnet,400on missing name,401without Bearer,POST /networks/cleanupremoved count.test/e2e/persistence.test.ts(5 tests) - light-runner as source of truth: run id is the container name,GET /runs/:idserved from the state dir, a fresh server instance recovers runs from disk (restart proxy),DELETEremoves the state file,GET /runslists from disk.
Contributing
Short-lived feature branches, squash-merged into main. No direct commits on main. Tags v* trigger the npm publish.
- CONTRIBUTING.md - branching model, PR guidelines, code style, local testing flow
- RELEASE.md - tag-based release, OIDC trusted publishing, guards, recovery paths
