Library guide

August 8, 2026 · View on GitHub

Build images from TypeScript, step by step. Use this when the image is generated — one per tenant, per test, per branch — rather than checked in as a Dockerfile.

1. Install

A normal project dependency; no global install, no -g:

bun add @beamhop/beambox   # or: npm i @beamhop/beambox

Node 20+ or Bun. Everything below is one import away:

import { image, dockerfile, dockerfileText } from "@beamhop/beambox"

2. Your first build

image(base) starts a spec, the fluent methods describe it, and build() produces it:

import { image } from "@beamhop/beambox"

const built = await image("alpine:3.20")
  .run("apk add --no-cache curl")
  .cmd(["/bin/sh"])
  .build({ tags: ["demo:local"] })

await built.load()   // into the microsandbox cache → msb run demo:local

build() resolves to a BuiltImage. Nothing is written anywhere until you ask for an output in step 6.

3. Specs are immutable

Every method returns a new spec. A base can be shared and branched without a later call reaching back and changing an earlier result:

const base = image("node:22-slim").workdir("/app").env({ NODE_ENV: "production" })

// `base` is unchanged by either of these.
const api = base.copy("./api/dist", "/app").cmd(["node", "index.js"])
const worker = base.copy("./worker/dist", "/app").cmd(["node", "worker.js"])

const [apiImage, workerImage] = await Promise.all([
  api.build({ tags: ["api:local"] }),
  worker.build({ tags: ["worker:local"] }),
])

That is the property the whole API is designed around — it is what makes generating a family of images from one description safe.

4. The methods

MethodDockerfile equivalent
image(base, { as, platform })FROM base AS name"scratch" starts empty
.stage(base, { as })A new FROM, beginning the next stage
.run(command, { mounts, env })RUN — a string is shell form, an array is exec form
.copy(sources, dest, { from, chown, chmod })COPY
.add(sources, dest, { from, chown, chmod })ADD, including tar auto-extraction
.env(values) / .arg(name, default) / .label(values)ENV / ARG / LABEL
.workdir(path) / .user(user)WORKDIR / USER
.cmd(command) / .entrypoint(command)CMD / ENTRYPOINTnull clears it
.expose(...ports) / .volume(...paths) / .shell(argv) / .healthcheck(...)the rest

.copy takes one path or an array, and globs are allowed. chown takes numeric IDs ("1000:1000") — names would require reading the image's /etc/passwd.

5. Multi-stage, with a cache mount

const built = await image("node:22", { as: "builder" })
  .workdir("/src")
  .copy(["package.json", "package-lock.json"], "./")
  .run("npm ci", { mounts: [{ type: "cache", target: "/root/.npm", id: "npm" }] })
  .copy(".", ".")
  .run("npm run build")
  .stage("node:22-slim")                       // second stage starts here
  .copy("/src/dist", "/app", { from: "builder" })
  .workdir("/app")
  .expose(3000)
  .cmd(["node", "index.js"])
  .build({ tags: ["app:local"] })

Three mount types: cache (a persistent microsandbox named volume, shared across builds), bind (a directory from the context or an earlier stage), and tmpfs. A cache mount is its own filesystem, so nothing in it ends up in the image.

6. Getting the image out

await built.load()                                       // microsandbox cache
await built.toArchive("app.tar")                         // docker save format
await built.toArchive("app.oci.tar", { format: "oci" })  // OCI Image Layout
await built.toLayoutDirectory("./out/oci")               // unpacked, for skopeo/oras/crane
const digest = await built.push("ghcr.io/me/app:v1")     // any OCI registry

Each takes an optional { tags } to override the tags given to build(). A BuiltImage is also a plain ImageArtifact, so built.config, built.manifest, and built.layers are there to inspect — useful in tests that assert on the result rather than run it.

7. Build settings

await spec.build({
  context: "./app",                    // what COPY and ADD read from; default: cwd
  tags: ["app:local"],
  platform: { os: "linux", architecture: "arm64" },
  buildArgs: { VERSION: "1.2.3" },
  cacheDir: "./.cache/beambox",        // default: ~/.cache/beambox
  noCache: true,                       // ignore cached RUN results
  registry: { insecure: true },
  onProgress: (event) => { /* see below */ },
})

onProgress receives a discriminated BuildEventstage, step, cached, pull, output, and warning — so a custom reporter is a switch:

onProgress: (event) => {
  if (event.kind === "step") console.log(`[${event.index + 1}/${event.total}] ${event.instruction}`)
  if (event.kind === "output") process.stderr.write(event.text)
}

8. Building an existing Dockerfile from code

Same engine, same events, same result type:

import { dockerfile, dockerfileText } from "@beamhop/beambox"

const source = await dockerfile("./Dockerfile", { context: "." })
const built = await source.build({ tags: ["app:local"], target: "builder" })

// `source.plan` is the parsed plan, if you want to inspect or transform it first.

dockerfileText(text, { context }) takes a string instead of a path — the right choice in tests, where the Dockerfile is part of the test case.

9. Private registries

Credentials in ~/.docker/config.json are picked up automatically. To be explicit:

const built = await image("registry.corp.io/team/base:v2")
  .cmd(["/app/server"])
  .build({
    registry: {
      auth: { kind: "basic", username: "deploy", password: process.env.REGISTRY_TOKEN ?? "" },
      insecure: false,
    },
  })

10. Declarative builds need nothing installed

A spec with no .run() never boots a VM and never loads the microsandbox SDK, so it runs on a machine with no container runtime at all — and, because nothing is executed, it can target any platform:

const built = await image("gcr.io/distroless/static")
  .copy("./server", "/server")
  .cmd(["/server"])
  .build({ platform: { os: "linux", architecture: "amd64" } })

await built.toArchive("server.tar")

Add a .run() and the same build becomes host-architecture only — beambox raises PlatformMismatchError rather than mislabelling the image.

11. Handling failures

Every failure is a class you can catch, and they all extend BeamboxError:

import { RunFailedError, UnsupportedInstructionError } from "@beamhop/beambox"

try {
  await image("alpine:3.20").run("exit 42").build()
} catch (error) {
  if (error instanceof RunFailedError) {
    console.error(error.exitCode)   // 42
    console.error(error.output)     // what the step printed
  }
  if (error instanceof UnsupportedInstructionError) throw error  // rewrite, do not retry
}

Also exported: DockerfileParseError (line and column), NoExecutorError, PlatformMismatchError, CopySourceError, UnknownStageError, RegistryAuthError, RegistryRequestError, ForeignLayerError, PlatformNotFoundError.

12. Testing a build

Declarative builds need no runtime, so they run in an ordinary unit test:

import { expect, test } from "bun:test"
import { image } from "@beamhop/beambox"

test("the server image exposes 3000 and starts the binary", async () => {
  const built = await image("gcr.io/distroless/static")
    .copy("./fixtures/server", "/server")
    .expose(3000)
    .cmd(["/server"])
    .build({ platform: { os: "linux", architecture: "amd64" } })

  expect(built.config.config?.Cmd).toEqual(["/server"])
  expect(Object.keys(built.config.config?.ExposedPorts ?? {})).toContain("3000/tcp")
})

For a RUN step, pass your own executor in build({ executor }) — the Executor interface is exported, and it is how the microsandbox executor is plugged in too.

Next