Observer
Observer Agent

Bun and distroless: design choices

Why the agent runs on Bun and ships in a distroless image.

The agent's runtime choices are deliberate. They shape the image size, the operator surface, and the security posture in production.

Bun

The agent runs on Bun rather than Node.js. The decisions Bun makes for us:

  • Native TypeScript execution. Source files run directly, with no transpile step in the build pipeline.
  • Embedded SQLite. The bun:sqlite module replaces better-sqlite3. Loses the build dependency on python, make, and g++. The container image shrinks accordingly.
  • Native fetch. axios is gone. One fewer dependency, one fewer attack surface.
  • Native scheduling. setInterval is enough; node-cron is not in the dependency graph.
  • Automatic .env loading. dotenv is gone for the agent's needs.

The trade-off is that Bun is younger than Node and the ecosystem's edge cases sometimes show up. The agent's dependencies are deliberately narrow to limit exposure.

Distroless

The runtime image is oven/bun:1-distroless. The trade-offs:

  • No shell. sh, bash, busybox, curl, wget, and every other utility are absent. An attacker who reaches the container has no shell to drop into.
  • No package manager. No apk, apt, or anything that can install code at runtime.
  • Smaller surface. The image contains the Bun binary, libc, and the agent's source. Nothing else.

Operational consequence: do not kubectl exec -it into the container expecting a shell. Diagnose through the dashboard, through logs, and through restart-and-observe.

Single-file source

The runtime entry is src/index.ts. Surrounding modules (buffer.ts, drain.ts, dashboard.ts, status.ts, sources/*) are ESM imports. There is no bundler step before shipping. The image carries the source verbatim, runs it under Bun, and that is the entire chain from git clone to running process.

Image size

The runtime image is roughly 40MB on linux/amd64. The Bun distroless base is most of that; the agent's own code adds a few hundred kilobytes. Pull time on a fresh node is dominated by the base layer; tag-based caching makes subsequent pulls nearly instant.

Standalone binary

bun build --compile produces a single-file executable per platform. The binary embeds the Bun runtime and every dependency, so a release-binary install reduces to "download, chmod, run" with no runtime install on the host.

Per-tag CI publishes five binaries to github.com/useobserver/agent/releases: linux-x64, linux-arm64, darwin-x64, darwin-arm64, and windows-x64.exe, plus a SHA256SUMS file for verification.

The binary path is the lightest install but offers fewer guardrails than the container path. Use the container image when you want isolation (separate user namespace), a single upgrade mechanism shared with other services, or tag-based version pinning at the container-runtime layer. Use the binary on constrained or air-gapped hosts that should not run Docker at all.

Binaries are produced from the same source as the container — the build entry is src/index.ts either way. Runtime flags can be forwarded to the embedded Bun via BUN_OPTIONS; see bun.com/docs/bundler/executables.

Was this page helpful?