panini 🥪 #
Press a Gleam (Erlang/BEAM) app into a single, self-contained binary.
A Burrito for Gleam. panini turns a
Gleam project that targets Erlang into one native executable that runs on a machine with
nothing installed — no Gleam, no Erlang, no rebar3. The BEAM runtime is bundled
inside.
panini build ./examples/hello -o ./hello
./hello # => Hello from hello! (runs with nothing installed)
It can select the OTP version to bundle, and cross-compile binaries for other platforms from a single machine.
panini build ./examples/hello --otp 27.2 # pick the OTP version
panini build ./examples/hello --target all --otp 27.2 # every platform at once
Contents #
- Install
- Commands
- Targets
- OTP version selection & the BEAM compatibility rule
- How it works
- Zig is handled for you
- Requirements
- Usage
- CI
- Development
- Roadmap
- Layout
- Name
- License
Install #
Prebuilt binary (macOS / Linux, x86_64 / aarch64) — from the releases:
# example: Linux x86_64
curl -fsSLO https://github.com/tsirysndr/panini/releases/latest/download/panini-v0.2.0-x86_64-linux.tar.gz
tar -xzf panini-v0.2.0-x86_64-linux.tar.gz
sudo install panini-v0.2.0-x86_64-linux/panini /usr/local/bin/panini
Homebrew (macOS / Linux):
brew install tsirysndr/tap/panini
Debian / Ubuntu (.deb for amd64, arm64) — from the Gemfury apt repo:
echo "deb [trusted=yes] https://apt.fury.io/tsiry/ /" \
| sudo tee /etc/apt/sources.list.d/panini.list
sudo apt-get update
sudo apt-get install panini
Fedora / RHEL (.rpm for x86_64, aarch64) — from the Gemfury yum repo:
sudo tee /etc/yum.repos.d/panini.repo <<'EOF'
[panini]
name=panini
baseurl=https://yum.fury.io/tsiry/
enabled=1
gpgcheck=0
EOF
sudo dnf install panini
Nix (flake):
nix run github:tsirysndr/panini -- --help # run without installing
nix profile install github:tsirysndr/panini # or install
Enable the Cachix binary cache first to pull prebuilt binaries instead of compiling locally — a much faster install:
cachix use panini
From source (Rust):
cargo install --git https://github.com/tsirysndr/panini
Commands #
| Command | What it does |
|---|---|
panini build [DIR] [OPTIONS] |
Press a Gleam app into one binary |
panini doctor |
Check the toolchain is ready |
panini targets |
List supported build targets |
panini otp-versions |
List OTP versions usable with --otp |
panini info |
Show the detected Gleam / OTP / Zig toolchain |
build options #
| Option | Default | Meaning |
|---|---|---|
-o, --output PATH |
<project>/<app> |
Output binary path |
--otp VERSION |
host's OTP | Bundle a specific OTP, e.g. 27.2 (downloaded) |
--target LIST |
host | Comma-separated targets, or all |
--compression KIND |
gz |
Payload compressor: gz, xz, or zst |
With multiple targets the output name gets a -<target> suffix (e.g. hello-x86_64-linux).
gz is the default because it decompresses with tooling present everywhere. xz and zst
produce a smaller binary, but the matching decompressor must be available on the target at run
time (Linux); macOS tar handles all three natively.
The bundled runtime and the app are packed and cached separately. On first run the runtime is
extracted once into a shared, content-addressed dir (~/.cache/panini/rt/<hash>), so several
binaries built with the same runtime don't each re-unpack their own copy of OTP; each app's own
files live under ~/.cache/panini/apps/<hash>.
Targets #
aarch64-macos x86_64-macos aarch64-linux x86_64-linux
x86_64-freebsd x86_64-netbsd x86_64-openbsd (host-only)
macOS runtimes are universal (one archive covers both arches); Linux is per-arch and
statically linked (musl). These four ship precompiled OTP, so they can be cross-built
from any host and are what --target all expands to.
The BSD targets are host-only: there are no precompiled OTP builds to fetch for them, so
they can't be cross-compiled or used with --otp. Build them on a FreeBSD/NetBSD/OpenBSD
machine that has Erlang + Gleam installed — panini bundles that host's own OTP:
# on a FreeBSD box:
panini build ./examples/hello -o ./hello # host build → bundles the host's Erlang
FreeBSD has the most mature Zig + Erlang support; NetBSD/OpenBSD are best-effort. Windows isn't supported yet (needs a different boot + launcher).
OTP version selection & the BEAM compatibility rule #
BEAM bytecode must be compiled by an OTP that is compatible with the runtime it runs on —
newer-compiler bytecode won't load on an older runtime. So --otp doesn't just swap the
runtime, it also controls how the app is compiled:
- Native target (building for this machine's OS): panini downloads the selected OTP and
compiles your app with it (via PATH shims), so the bytecode always matches the bundled
runtime. Any version works, and no system Erlang is required —
--otpprovides the whole toolchain. - Cross-OS target (e.g. Linux from macOS): panini can't run the target's compiler, so the app is compiled by the host toolchain. The bundled OTP major must therefore equal the host's OTP major; panini checks this and tells you if it doesn't. (This is the same constraint Burrito has.)
panini otp-versions lists what you can pass to --otp (precompiled runtimes exist for OTP
25.3+; macOS builds are universal).
How it works #
gleam export erlang-shipment .beam + .app files (portable bytecode)
│
▼
minimal, relocatable OTP runtime erts + boot files + only the OTP lib apps
(host runtime or downloaded) the app references (kernel, stdlib, …)
│
▼
run.sh sets ROOTDIR/BINDIR and boots via `erlexec`
│ with an explicit -boot (no OTP `Install` needed)
▼
tar + gzip -> payload.tar.gz
│
▼
Zig self-extracting launcher @embedFile(payload); first run extracts to
(cross-compiled per target) ~/.cache/panini/<app>-<hash>/ then execs run.sh
- The CLI (
src/) is Rust, std-only — it shells out togleam,erl,curl,tar, andzig. No crate dependencies. - The launcher (
launcher/) is Zig (pinned to 0.16.0), in the spirit of Burrito's wrapper: it embeds the compressed payload, self-extracts to a per-app cache dir on first run, and hands off viaprocess.replace(exec). Cross-compiled withzig build -Dtarget=…. - BEAM bytecode is portable, so the Gleam shipment is reused across targets — only the native runtime and launcher differ per platform.
Zig is handled for you #
The launcher uses Zig-0.16.0-only APIs, so panini needs exactly that version. It uses your
zig if it reports 0.16.0; otherwise it downloads Zig 0.16.0 into
~/.cache/panini/zig/ once and uses that. You never have to install Zig yourself.
Requirements #
Build machine needs gleam, curl, and tar. A host erl is only needed for the default
host-OTP build; a --otp <v> build downloads and compiles with its own OTP. zig is
auto-provisioned. Run panini doctor to check.
The target machine needs only a POSIX sh and tar (universal on macOS/Linux).
Usage #
cargo build --release
./target/release/panini build ./examples/hello -o ./hello
./target/release/panini build ./examples/hello --otp 28.0
./target/release/panini build ./examples/hello --target x86_64-linux,aarch64-linux --otp 27.2
./target/release/panini doctor
CI #
.github/workflows/e2e.yml runs the real thing on every push:
- bundle — a matrix of {ubuntu x64/arm64, macOS x64/arm64} × {OTP 26, 27, 28}: builds a binary with a bundled OTP (no system Erlang; Zig auto-downloaded) and runs it.
- native — the host-OTP path via
erlef/setup-beamacross OTP 26/27/28. - cross-build → cross-run — cross-compiles an
aarch64-linuxbinary on an x86_64 runner and runs it on a real arm64 runner, proving cross-compiled binaries work end-to-end. - cli —
clippy -D warnings,fmt --check, and the CLI subcommands.
nix.yml builds the flake on Linux + macOS (nix build + nix flake check). release.yml
builds binaries + .deb/.rpm for macOS/Linux × amd64/arm64 on tag push, publishes them to
GitHub Releases, and pushes the packages to Gemfury.
Development #
nix develop # Rust toolchain + gleam + erlang + fetch/extract tools
cargo build --release
Roadmap #
Layout #
src/ Rust CLI: main, pipeline, otp, target, zig
launcher/ Zig 0.16 self-extracting wrapper (embedded into panini via include_str!)
examples/hello/ a sample Gleam app to build
dist/ .deb / .rpm packaging templates
flake.nix Nix package + dev shell
.github/ e2e, nix, and release workflows
Name #
A panini is a pressed sandwich — which is exactly the operation: press your app and its runtime flat into one binary. Also a nod to Pāṇini, who wrote the first formal grammar — fitting for a tool built on a typed, compiled language.
License #
MIT