Appaloft is an open-source deployment control plane. It is built for people who want to deploy from local projects, Git workflows, and automation into infrastructure they can still understand when something goes wrong.
We did not pick Bun because “written in Bun” sounded good on a README badge. The boring requirement came first: Appaloft should be easy to try locally. Ideally, one CLI or binary could start the backend, serve the Web console, serve public docs, and use a local PGlite database by default.
That is where Bun started to look useful. Appaloft is a TypeScript monorepo, but the product is not just a library or a web app. It is a CLI, a backend server, a static Web console, public docs, release tooling, smoke tests, Docker images, and binary artifacts.
Bun made that release shape possible without a pile of extra glue. It did not make the runtime decisions disappear.
Why Bun was attractive
The public Appaloft repository pins Bun directly:
{
"packageManager": "bun@1.3.14",
"engines": {
"bun": ">=1.3.14"
}
}
The same repository uses Bun for day-to-day scripts, smoke paths, and release packaging:
{
"serve": "bun run --cwd apps/shell src/index.ts serve",
"package:binary-bundle": "bun run scripts/release/build-binary-bundle.ts",
"package:deploy-cli-bundle": "bun run scripts/release/build-deploy-cli-bundle.ts",
"package:artifacts": "bun run scripts/release/build-artifacts.ts"
}
The CLI package keeps the build path similarly direct:
{
"bin": {
"appaloft": "./src/index.ts"
},
"scripts": {
"build": "bun build src/index.ts --outfile dist/appaloft --target bun"
}
}
This is not a claim that every project should collapse onto one runtime. For us, the nice part was smaller and more concrete: release scripts, tests, source CLI execution, and compiled output could all stay in the same TypeScript-oriented workflow.
The Bun features that mattered most were:
- direct TypeScript execution for scripts and CLI development
bun build --compilefor single-binary-style distribution- file imports with
with { type: "file" } Bun.file,Bun.write, andBun.resolveSyncin release tooling- a shared
bun runandbun testworkflow across the monorepo
The speed is nice. The bigger win was that packaging stopped feeling like a separate mini-project.
The core design: embedding the frontend into the binary
The most Bun-specific release code lives in scripts/release/lib/binary-bundle.ts. The binary bundle pipeline is intentionally explicit:
- Build the Web console.
- Build the public docs.
- Scan the static output directories.
- Generate
embedded-web-assets.generated.ts. - Generate
embedded-docs-assets.generated.ts. - Import each file with
with { type: "file" }. - Convert those imports into route-keyed
Blobmaps. - Generate a temporary
binary-entry.ts. - Compile that entry with
bun build --compile.
The generated asset module is conceptually this:
import asset0 from "./build/index.html" with { type: "file" };
import asset1 from "./build/_app/entry.js" with { type: "file" };
export const embeddedWebAssets = {
"/index.html": Bun.file(asset0),
"/_app/entry.js": Bun.file(asset1),
} as const satisfies Readonly<Record<string, Blob>>;
The real release script does that for every file in the Web build and every file in the docs build. The temporary binary entry then imports:
embeddedWebAssetsembeddedDocsAssetspglite.datapglite.wasminitdb.wasm- the actual shell runner
Then the release script compiles the generated entry:
bun build dist/.tmp-binary-bundle/binary-entry.ts \
--compile \
--target=<release-target> \
--outfile <out-dir>/appaloft
This is the part that made Bun feel like the right tool. File imports gave us a straightforward way to turn a static UI and docs tree into runtime data without first inventing an asset-pack format.
But this was also the first place where the nice demo version ended. --compile gives you a binary. It does not tell the product what /docs means, how clean URLs should resolve, when an SPA fallback is correct, or how operators can override embedded files.
PGlite made the local-first story more interesting
Appaloft defaults to PGlite unless configuration points it at Postgres. The public config resolves the database driver from explicit flags, environment, config file, or the presence of APPALOFT_DATABASE_URL.
That means the low-friction local path can be:
appaloft serve
while the external database path remains:
APPALOFT_DATABASE_DRIVER=postgres
APPALOFT_DATABASE_URL=postgres://...
appaloft serve
The binary bundle embeds the PGlite runtime files:
pglite.data
pglite.wasm
initdb.wasm
At runtime, the generated entry only loads those embedded PGlite assets when the active database driver is PGlite. In the persistence layer, PGlite and Postgres enter through the same database creation boundary: PGlite creates an embedded Kysely dialect, while Postgres requires a database URL.
This is a tradeoff we are still careful about. PGlite reduces the cost of trying Appaloft and running a local control plane. It should not make the production path feel like an afterthought. The local default and the Postgres path need to coexist rather than compete.
Runtime boundary: embedded assets vs filesystem assets
The single binary embeds Web and docs assets. Docker does not.
The public Dockerfile builds the shell, Web console, docs, and PGlite runtime assets in a builder stage. The runtime image then copies the static outputs into ordinary directories:
COPY --from=builder /app/apps/web/build /app/web
COPY --from=builder /app/apps/docs/dist /app/docs
ENV APPALOFT_WEB_STATIC_DIR=/app/web
ENV APPALOFT_DOCS_STATIC_DIR=/app/docs
That split is deliberate. For a binary archive, embedded assets make installation simpler. For a Docker image, ordinary files are easier to inspect, layer, replace, and cache.
The server supports both paths. createAppaloftServer can receive embedded Web and docs assets, but it can also resolve static directories from configuration or local build output. The HTTP adapter then serves from either source.
The adapter has to handle several cases:
- direct static file match
- route
index.htmlmatch - clean URL
.htmlmatch - Web console SPA fallback
- docs paths under
/docs - embedded
Blobassets - filesystem assets
- path traversal protection for filesystem reads
The Web console uses fallback-to-root behavior because it is an SPA. Docs do not use the same fallback. That difference is small in code and very visible when it is wrong.
The asset map was the easy part. The less glamorous work was making /docs, clean URLs, SPA fallback, filesystem overrides, and embedded Blob responses behave consistently.
What worked
Bun scripting made the release pipeline easier to keep in TypeScript. The bundle script removes directories, runs builds, scans files, generates modules, resolves package assets, writes launchers, and compiles the final binary without switching to a separate scripting language.
bun build --compile made CLI/server distribution more direct. Instead of bending the application around a release format, we generate a release-specific entry module and compile that.
File imports made asset embedding feel ordinary. The generated modules are boring in a good way: import file, wrap with Bun.file, expose a Record<string, Blob>.
The multi-target model became explicit. Appaloft models release targets for macOS, Linux, and Windows, including Linux glibc and musl targets. Each target carries a Bun target, npm binary package name, executable name, archive format, and related platform metadata.
The monorepo workflow also stayed consistent. bun run, bun test, bun build, and TypeScript release scripts are all part of the same daily vocabulary. That sounds mundane, but mundane is good when the release script is already doing enough work.
What surprised us
--compile does not design the serving contract. You still have to decide where assets come from, how they are addressed, and what happens when the URL does not map to a literal file.
Frontend routing details become backend responsibilities once the backend serves the frontend. Appaloft has to distinguish Web console SPA fallback from docs path handling. It also has to support clean URLs and route-level index.html files.
macOS signing needed an explicit step. This one was easy to miss: --compile mutates Bun’s signed executable, so the release script removes the stale signature and ad-hoc signs the generated Darwin executable.
Docker and binary bundles wanted different answers. The binary distribution benefits from embedded assets. The Docker image benefits from /app/web and /app/docs.
Embedded assets are convenient, but they should not be the only path. Appaloft keeps APPALOFT_WEB_STATIC_DIR and APPALOFT_DOCS_STATIC_DIR as overrides so operators and packagers are not trapped in the binary layout.
Where it still hurts
Binary size and build time need continuous measurement. Embedding the Web console, docs, and PGlite runtime assets is convenient, but it is not free. Before publishing this as a final post, we should add per-target binary sizes and release build timings.
Cross-platform targets are real work. macOS, Linux glibc, Linux musl, and Windows differ in executable naming, archive format, runtime assumptions, signing, and downstream package mapping.
Compiled binary debugging is not the same as source development. Running TypeScript directly during development is pleasant. Debugging a generated entrypoint with embedded assets requires a different mental model.
Embedding UI and docs means updating UI and docs requires a new binary. That is a good fit for self-contained archives, but not always the best operational model. The Docker filesystem strategy exists because distribution formats have different needs.
PGlite is excellent for a local-first default, but Appaloft still has to keep the Postgres path strong. A deployment control plane should make the first run easy without making the team/production path feel bolted on later.
Closing
For Appaloft, Bun’s biggest value was not speed by itself. It was that Bun made this release shape realistic:
TypeScript app
+ CLI
+ backend server
+ static Web console
+ static docs
+ embedded PGlite runtime assets
+ optional compiled binary
That is a specific shape, and Bun fits it well.
The tradeoff is that Bun gives useful primitives, not a product architecture. We still had to decide how embedded assets differ from filesystem assets, how PGlite differs from Postgres, why Docker and binary packaging should not share the same asset strategy, and where source development stops matching compiled releases.
If you are shipping compiled Bun CLIs or local-first servers, I would love to compare notes. The areas I am most curious about are asset embedding, large static bundles, glibc/musl target strategy, and how teams debug compiled binaries once the release pipeline gets more serious.