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. The rest of the agent is a set of ESM modules with no bundler step; Bun runs the source directly. 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?