Single file apps with Gleam and Bun

21 Aug 2026

Gleam is a friendly language primarily used for writing fault-tolerant and often distributed systems. It implements a Hindley-Milner type system, which is a type system that allows for maximum type-safety without sacrificing productivity. Most users of Gleam choose to run it in the BEAM, which is the same fantastic Virtual Machine that Elixir and Erlang run in. Basically, Gleam is really very good.

Bun, which I work on, has support for Single File Executables. This feature of Bun's bundler enables bundling your code with the runtime itself, into a single binary as output. These binaries are completely standalone and do not require your end user to have any extra packages installed on their system. They can be cross-compiled and you can target both glibc and musl on Linux. Basically, Bun single file executables are really very good.

So how can we combine these two really very good things?

Writing our program

Conveniently, Gleam has a JavaScript backend. That means that, as well as targeting the BEAM, Gleam can also output JavaScript. Frameworks like Lustre use this to let you write full-stack apps with Gleam that run in browsers, while libraries like gossamer provide idiomatic Gleam bindings to JavaScript APIs that exist in our runtime(s) such as fetch(), crypto, Worker, URL, etc.

So, if we want to use Bun's single file executable feature but author our program in Gleam, it is precisely writing idiomatic Gleam bindings for APIs that exist in our JavaScript runtime that we are interested in.

Here's a quick and dirty example that exposes two of Bun's APIs to Gleam:

src/bun_ffi.mjs

// Bun.file(path).text() -> Promise<string>
export function readText(path) {
	return Bun.file(path).text();
}

// Bun.semver.satisfies(version, range) -> boolean
export function semverSatisfies(version, range) {
	return Bun.semver.satisfies(version, range);
}

src/app.gleam

import gleam/io
import gleam/javascript/promise.{type Promise}

@external(javascript, "./bun_ffi.mjs", "readText")
fn read_text(path: String) -> Promise(String)

@external(javascript, "./bun_ffi.mjs", "semverSatisfies")
fn semver_satisfies(version: String, range: String) -> Bool

pub fn main() {
	use version <- promise.map(read_text("./VERSION"))
	case semver_satisfies(version, "^2.0.0") {
		True -> io.println("v" <> version <> " is compatible")
		False -> io.println("v" <> version <> " needs migrating")
	}
}

Don't worry if you are unfamiliar with the Gleam code above. The most important lines are those with @external attributes. This is syntax for telling the Gleam compiler that X function will exist at runtime, accepting Y arguments, returning Z value, but the implementation lives somewhere else. These types are not checked, so it's on the developer to make sure they are correct. In our case, the implementation lives in a separate JavaScript file on the disk, which relative to the Gleam file is ./bun_ffi.mjs.

We can run this code with the Gleam CLI itself, with two extra flags.

gleam run --target=javascript --runtime=bun

Downloading packages

Downloaded 2 packages in 0.01s

Compiling gleam_stdlib

Compiling gleam_javascript

Compiling app

Compiled in 0.03s

Running app.main

v2.1.0 is compatible

Nice! We just called some Bun APIs from our Gleam code, and even dealt with promises in the process! This is great, but there are two problems:

To run our program...

  1. ...we need both Gleam and Bun installed...
  2. ...and we need the original source code.

This, of course, is a very unfriendly user experience and makes it virtually impossible to distribute our application in any way that a normal user (or even developer) would expect.

Building our program

We've been using gleam run, which is fine while we're exploring, but it's strange to run our app just to generate build artifacts. Instead, Gleam provides us with a gleam build command.

gleam build --target=javascript

Compiled in 0.00s

Note that --runtime=bun is gone, since it's a gleam run flag only. It only ever told Gleam which runtime it should shell out to, and the JavaScript it emits is identical anyway.

Gleam just created a build/dev/javascript/ directory. The contents are nothing exotic - it's simply JavaScript. In fact, Gleam's JavaScript backend generates extremely human-readable and modern JavaScript, even using ECMAScript Modules (ESM). This is great!

Our src/app.gleam compiled to build/[..]/app.mjs, which exports our main function but doesn't call it. So, we can write a small entrypoint that does:

entry.mjs

import {main} from "./build/dev/javascript/app/app.mjs";
main();

Which we can run with Bun, directly:

bun entry.mjs

v2.1.0 is compatible

It works! Amazing.

So... instead of running that entrypoint directly, what if we hand it to Bun's bundler and ask for a binary?

bun build --compile entry.mjs --outfile dist/app

[4ms] bundle 23 modules

[58ms] compile dist/app

That command generates a dist/app single file executable. You can copy it onto a machine without Gleam or Bun installed, and without any of the source code, and it will still run just fine. It's entirely self-contained and standalone. Both of the problems we had earlier are gone. We have made our Gleam program much easier to distribute!

Recall that our @external attributes pointed at ./bun_ffi.mjs, relative to the Gleam file. When Gleam compiled our program, it copied that file into build/dev/javascript/app/, right next to the app.mjs it generated. The relative import still resolves, so Bun's bundler just follows it the same way it would follow any import in any other project. Bun sees ECMAScript Modules with a relative import, and bundles them, and it has no idea that Gleam was even involved at any point!

And because that output is an ordinary Bun executable, the rest of the bundler is still available to us. We can cross-compile from a Mac straight to a Linux ELF:

bun build --compile --target=bun-linux-x64 entry.mjs --outfile dist/app-linux

[4ms] bundle 23 modules

[58ms] compile dist/app-linux bun-linux-x64-v1.4.0

There are targets for Linux, macOS and Windows, on both x64 and arm64, and on Linux you can pick glibc or musl. If it hasn't already, Bun will download the runtime for whichever target you asked for, so the first cross-compile is slightly slower than the rest.

That's it - only two commands - a Gleam build and then a Bun build. This also works in a fresh checkout because Gleam will install missing dependencies during gleam build.

I'll soon write a Part II post about how we can take this a step further, and write desktop apps with Gleam. In short, you could probably imagine what using Gleam's JavaScript target with bindings to frameworks like Electron might look like.

Happy shipping.


Thank you to Landon Boles who helped me with a draft of this post.