# Configuration reference

URL: https://helmr.dev/docs/reference/configuration
Description: Task project, Workspace, image, and Run configuration.

# Configuration reference

## Task project

A task project must contain `package.json` and `helmr.config.ts`.
`.helmrignore` is optional source-selection authority and is generated by
`helmr init`.

`helmr.config.ts` is the single place for Helmr build configuration. The CLI
evaluates it **once, on the machine that runs `helmr build` or `helmr deploy`**,
before any Linux environment exists, and hands the result to every later phase;
the Linux build never imports the config again.

- Host requirement: Node.js 22 or newer on `PATH`. Helmr does not download Node.
- The config is ordinary TypeScript: relative helpers (with or without file
  extensions), enums, tsconfig `paths`, and installed ESM/CommonJS packages,
  including packages hoisted to a parent `node_modules`, resolve as usual. It is
  transpiled, not type-checked, and is not guaranteed to match the task-module
  loader in every detail.
- Prepare what the config imports on that machine, normally with your package
  manager (at least `@helmr/sdk`). Helmr never installs host dependencies, and
  host `node_modules` are never copied into the Linux build. A config that
  imports a native package needs that package built for the host.
- The config is trusted local code, like a package script: it runs with your
  environment and is not sandboxed. Helmr adds no credentials of its own to it.
- Build from a stable checkout: source is captured first, then the live config
  is evaluated; Helmr does not promise an atomic snapshot of everything a config
  might read.

The builder respects a top-level `packageManager` selector or an unambiguous
npm, pnpm, Bun, or Yarn lockfile when present. A lockfile with a frozen install
is the recommended reproducible path, not a Control Plane admission condition.
Package-manager identity, version, lockfile, and install command remain producer
details; the Control Plane accepts only the verified final bundle closure and
its execution Runtime contract.

### Build settings

```ts
import { builder, defineConfig } from "@helmr/sdk"

export default defineConfig({
  dirs: ["tasks"],
  build: {
    builder: builder()
      .copy("build/setup.sh", "/opt/setup.sh")
      .run(["/bin/sh", "/opt/setup.sh"]),
    installCommand: "./scripts/install.sh",
    secrets: ["NPM_TOKEN"],
  },
})
```

| Setting | Meaning |
| --- | --- |
| `build.builder` | Ordered preparation of the Linux build environment, run before dependencies are installed. Omit it to use Helmr's builder image unchanged. |
| `build.installCommand` | Replaces package-manager inference. One shell command run unprivileged with `/bin/bash -euo pipefail -c`. |
| `build.secrets` | Names of variables in the invoking environment. Each is mounted as `/run/secrets/NAME` during dependency installation only; values are never written to the config result or the bundle, and never sent to the Control Plane. |

Helmr's builder image is a standard digest-pinned Debian environment with the
usual ELF loader, Git, CA certificates, Python, make and the C/C++ toolchain,
plus the Product-selected official Node.js, npm, Corepack and Bun. Lifecycle
scripts, development dependencies, native compilation and executables shipped
by dependencies behave as they do on an ordinary Linux machine. Installation
runs as an unprivileged user.

`builder()` is its own role, not a Workspace `image()`: it always starts from
Helmr's builder image and offers only `copy(source, destination)` and
`run(argv)`.

- `copy` reads a file or directory from the **captured project source** (after
  `.helmrignore`, before installation) by project-relative path; a project
  `.dockerignore` has no effect. Paths are literal, not patterns or templates:
  `[` and `$` mean themselves in sources and destinations. A source may not
  contain `*`, `?` or `\` (copy its directory instead); a destination may not
  contain `\`. Destinations under `/opt/helmr`, `/nix` and
  `/workspace` belong to Helmr; use locations such as `/usr/local` or
  `/opt/<name>`.
- `run` executes an argv without a shell, on Linux, as root with working
  directory `/`, `HOME=/root`, `TMPDIR=/tmp` and `XDG_CACHE_HOME=/root/.cache`.
  Each step starts from that same context; files persist, shell state does not.
  Use a copied script for multi-line setup.
- The steps run once per build. The resulting environment is stored, and that
  exact image is used both for dependency installation and for evaluating task
  modules, so a step whose result varies cannot differ between phases. Task
  modules are evaluated with Helmr's own compiler, Runtime and builder mounted
  read-only from the pinned image; final assembly uses the pinned image alone.
- What the environment installs is a build input and is not exported: only the
  installed project tree becomes the Program. Workspace images declare their
  own runtime packages.

```ts
import { defineConfig } from "@helmr/sdk"

export default defineConfig({
  dirs: ["tasks"],
  ignorePatterns: ["**/*.test.ts"],
})
```

`dirs` defaults to `["tasks"]` and must be non-empty when provided. It selects declaration discovery
from the submitted project source. `ignorePatterns` affects discovery only.
`.helmrignore` is the only source-selection file; `.gitignore` is not read.
The root `.git` entry is always excluded. Retained root `node_modules` and
`helmr` paths are rejected. Retained `.env` and `.env.*` basenames are rejected
except names ending in `.example`, `.sample`, or `.template`.

`helmr build` copies the selected source into a private directory before starting
BuildKit. Regular files have independent inodes; later checkout edits do not
change the completed copy. Capture rejects changes it observes while reading and
rechecking entries, but is not an atomic filesystem snapshot. Files retain their
executable status, and captured timestamps are normalized.

Source capture accepts UTF-8 paths with components up to 255 bytes and depth up
to 128, subject to the actual host path limit and the Linux build and Program
mount prefixes. Relative symlinks must remain confined, with at most 40 hops and
4,095 bytes per target (or the lower host limit); dangling links are allowed. Filesystem name collisions or normalization that loses the original
spelling fail explicitly. Source is limited to 512 MiB of regular-file content,
100,000 selected entries, and a 128 MiB retained observation/name budget that also
charges observed ignored children. Exclude unnecessary caches with `.helmrignore`
when they exceed these limits.

Installed dependencies use the same filesystem path ceiling with separate
bounds: 10 GiB of regular-file content, 1 GiB per file, 200,000 entries including
the root, and 128 MiB of path and link names. The intermediate PAX stream is
limited to 11 GiB including metadata and padding.

## Installed dependencies and source execution

Helmr installs dependencies once for the target Linux platform and retains the
whole installed tree. Config, declaration analysis, and Program execution use
one platform-owned Node 24.21 language adapter. JavaScript keeps native Node
resolution, package exports, module cache and file locations. Reached TypeScript
and JSX files are transformed in memory using the Runtime-pinned TypeScript version, including
files under `node_modules`, mixed JavaScript-to-TypeScript packages, and dynamic
loads. There is no package selection setting and no dependency reinstallation by
name. Aliases, nested versions, hoisted packages and contained package symlinks
use their actual installed instances.

Sources retain their original URLs and extensions. ESM `import.meta.url`,
`import.meta.resolve()`, adjacent assets, CommonJS filenames, `createRequire`,
and computed loads use Node's native locations. Module identity follows Node:
ESM URLs and CommonJS filenames have their usual cache behavior; different
conditional export targets can still be separate instances. Installation must
produce native artifacts before the tree is frozen.

Native code has two distinct targets. Addons loaded by Program code run inside
the platform Node: x86_64 Linux, that Node's ABI, and the Runtime's own glibc
and libstdc++ (currently glibc 2.41), which are always used instead of the
Workspace image's. Other shared libraries an addon links resolve the ordinary
way inside the Workspace: the addon's RPATH, then the image's `ld.so.cache` and
standard library directories. The Workspace image therefore supplies them: a
project that compiles against `libvips-dev` in its build environment installs
`libvips42` in the Workspace images that run it. A missing library, or one built
against a newer glibc than the Runtime's, fails with the usual loader error
when the addon is imported. Executables that Program code spawns are ordinary
Workspace processes: they use the Workspace image's loader and libraries, and
Helmr injects nothing into them. One installed tree serves every Workspace of a
Deployment; a musl image can run it when its native code is self-contained.

Missing imports fail when reached. Unused optional dependencies are not eagerly
resolved. The retained tree includes dormant files and assets; admission binds
all of their bytes, executable modes, directory entries and symlink targets.
Declaration indexes identify original source paths and exports, not generated
customer bundles. Runtime compilation cannot load generated source from a mutable
Workspace. Put Program control code in the captured project; arbitrary commands
and tools inside a Workspace remain under the Workspace image's own execution
rules.

## Config and language semantics

`helmr.config.ts` is evaluated once per preparation, then its ordinary data is
validated and canonicalized before declaration analysis. Computed expressions,
variable default exports, ESM and CommonJS projects are supported. The default
must be an object: `{}` is valid; absent, `undefined`, `null`, functions, getters
and unknown settings fail. Configuration does not control its own evaluator.

The root config is build-only. Program imports of its canonical file, including
aliases, symlinks, query URLs and dynamic imports, fail. Shared data and helpers
belong in ordinary modules. Authoring scripts outside the managed compiler may
still import the config.

JavaScript and TypeScript/JSX importers use the nearest contained `tsconfig.json`
and its contained `extends` chain for resolution in every phase. Linked sources
use their canonical target ancestry; copied packages use their installed ancestry.
An absent config means empty project
options. Invalid JSONC, missing extended configs, cycles and escaping reads fail.
This is isolated TypeScript transformation, not typechecking or a promise of all
`tsc`, Bun or future TypeScript syntax. Project output settings are ignored;
Node module format is authoritative. JSX preserve settings lower to classic JSX;
configured automatic JSX, factories, decorators and class-field lowering apply.

For all contained source importers, `paths` aliases match exact keys first, then the
longest wildcard prefix/suffix; targets are attempted in order, with `baseUrl`
for otherwise unmatched bare imports. Missing alias candidates fall back to
native resolution. Builtins and package `#imports` stay native. JavaScript bytes
are not transformed; these resolution rules also let JavaScript reach TypeScript
helpers in a mixed source graph.

Relative imports try Node first. A missing `.js`, `.mjs` or `.cjs` target can use
`.ts`, `.mts` or `.cts` respectively. Extensionless fallback tries `.js`, `.mjs`,
`.cjs`, `.ts`, `.tsx`, `.mts`, `.cts`, `.jsx`, then directory indexes in that order.
Existing JavaScript wins collisions. Broken package exports, package configs and
syntax errors are not treated as missing candidates. Explicit extensions avoid
ambiguity.

Alias targets are filesystem paths. ESM relative imports use URL encoding for
literal `#`, `%` and `?` filename characters; query/fragment URLs retain separate
module identities. Relative `require()` names treat those characters literally.

Managed entry requires a contained regular object `package.json` at the project
root, no ancestor `node_modules` entries, and disabled global/`NODE_PATH` lookup.
An unsupported image layout fails explicitly; Helmr does not alter the image to
mask it. Actual file source and TypeScript config reads must stay inside the
Program. Deliberate path-directed image metadata lookups, user filesystem access,
custom hooks and process effects remain native image authority. These module
rules are not a tenant sandbox or a guarantee that arbitrary image-dependent
code behaves identically during build and execution. Helmr does not inject
`NODE_OPTIONS` into arbitrary Workspace tools.

## Runtime configuration

| Surface | Fields |
| --- | --- |
| `task` | `id`, `payload`, `queue`, `maxDuration`, `ttl`, `retry`, `run` |
| `actor` | `id`, `idleTimeout`, `queue`, `maxDuration`, `ttl`, `retry`, `run` |
| `sandbox` | `sandbox({ id }).image(img).resources({ cpu, memory })` |
| `image` | `from`, `run`, `copy`, `copyFrom`, `workdir`, `env`, `user` |
| `source` | `file(path)`, `directory(path)` |

SDK Workspace creation uses plain Secret names:

```ts
secrets: [
  { secret: "TOKEN", env: { name: "TOKEN", mode: "raw" } },
  {
    secret: "config-json",
    file: { path: "/run/secrets/config.json" },
  },
]
```

The corresponding REST request body uses the canonical wire form:

```ts
secrets: [
  { secret: "TOKEN", env: { name: "TOKEN", mode: "raw" } },
  {
    secret: "config-json",
    file: { path: "/run/secrets/config.json" },
  },
]
```

Secret names must match `/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/`.

## Local bundle builder

`helmr build` and source-based `helmr deploy` prepare a dedicated Docker Buildx
`docker-container` builder. Docker with Buildx and a running selected daemon are
required. `deploy --bundle` does not prepare a local builder. Helmr freezes the
native Docker context/endpoint selection for the build, preserving its TLS and
authentication settings. An explicit `DOCKER_CONTEXT` takes precedence over
`DOCKER_HOST`; otherwise Docker's native selection applies. `BUILDX_BUILDER` does
not select Helmr's builder, and Helmr never changes the global selected builder.

The reserved name is `helmr-` followed by the first 24 lowercase hex characters
of SHA-256 over `contextName + "\n" + endpointURI` (no final newline). It is shared
across projects on that context/endpoint. Helmr requires exactly one
`docker-container` node pointing to that endpoint before bootstrap, then verifies
that it is running. An incompatible existing resource is an error; Helmr will
not delete, append to, or reconfigure it.

For deliberate custom BuildKit settings, pre-provision that reserved name using
native `docker buildx create --name NAME --driver docker-container`, with the
selected context as its positional endpoint (the resolved URI for `default`).
Use Docker's `--buildkitd-config` and `--driver-opt` for required registry/network
settings. Do not pass `--use`. The same identity checks apply; there is no arbitrary
builder selector or driver fallback. Reconfigure through deliberate native
removal/recreation when no Helmr builds are using the resource.

The container and native cache remain after success, failure or cancellation for
reuse. When no builds are active, use `docker buildx stop NAME` to stop it or
`docker buildx rm NAME` to remove it and its cache, under the same Docker context
and Buildx configuration. The next source build recreates an absent builder.
