Package your Elixir applications as 100% self-contained executables. No Erlang/Elixir installation required on the target machine.
Features
- Self-contained binaries: Single executable with your app + ERTS embedded
- Smart ERTS provisioning: Auto-detects platform or force specific target
- Cross-compilation: Build for Linux (glibc/musl), macOS from any platform
- Zstandard compression: Optimal balance between size and speed
- Multiple execution modes: CLI, TUI, Daemon, and Escript support
- BEAM-keeps-alive daemon mode (Unix): wraps a persistent Erlang VM behind the binary so repeated invocations skip the cold-start cost (mix release + cargo build + Erlang boot — ~10-25s → ~5-15ms on warm calls). Unix-domain socket + length-prefixed JSON protocol; per-app/version/target namespaced; configurable TTL
- Relativized releases: Portable binaries with no absolute paths
- Automatic build cleanup: Intermediate artifacts are wiped after build, leaving the system pristine while preserving ERTS cache
- Build Environment Isolation: Automatically isolates the build from version managers (
asdf,mise,kerl) to prevent ERTS mismatches - Robust downloads: Automatic retry with exponential backoff on network failures
- Concurrent-safe caching: File-based locking prevents race conditions in multi-process builds
- Clear error messages: Specific error codes for disk full, permission denied, corrupted archives
Requirements
- Erlang/OTP 25+
- Elixir 1.15+
- Rust (cargo)
- Zstandard (zstd)
Banner Dependencies (Optional)
When show_banner: true (default), the build process displays a banner image in the terminal. To enable full image support across all terminal emulators, install these dependencies:
macOS
# For Sixel support (Alacritty, Ghostty, other terminals)
brew install libsixel
# Optional: for ASCII art fallback
# img2txt is included in libsixel
Linux
# Ubuntu/Debian
sudo apt install libsixel-tools
# Arch Linux
sudo pacman -S libsixel
# Fedora
sudo dnf install libsixel
Terminal Compatibility
| Terminal | Protocol | Requires |
|---|---|---|
| iTerm2 | Inline Images | Built-in |
| Ghostty | Kitty protocol | Built-in |
| WezTerm | Kitty protocol | Built-in |
| Alacritty | Kitty protocol | Built-in |
| Kitty | Kitty protocol | Built-in |
| VS Code | Sixel | libsixel |
| foot | Sixel | libsixel |
| Other terminals | ASCII fallback | None |
If no image support is detected, the banner falls back to text-only mode.
Quick Start
1. Add Dependency
# mix.exs
def deps do
[{:batamanta, "~> 3.0", runtime: false}]
end
2. Configure (Auto-detect)
def project do
[
app: :my_app,
version: "0.1.0",
batamanta: [
format: :escript, # :escript | :release
erts_target: :auto, # Auto-detect host platform (RECOMMENDED)
execution_mode: :cli, # :cli | :tui | :daemon
compression: 3, # 1-19 (zstd level)
binary_name: "my_app", # Optional: custom binary name
show_banner: true # Optional: show build banner
]
]
end
Banner image protocol
With show_banner: true (the default) the build draws a banner image, which
needs the terminal to speak an inline-image protocol. Detection reads the
environment and picks one of:
| Terminal | Protocol |
|---|---|
| kitty, ghostty, WezTerm, konsole, WaveTerm | kitty graphics |
| iTerm2 | iTerm2 inline images |
| Alacritty, foot, VS Code terminal | Sixel |
| anything else, or stdout not a TTY | text only |
If detection picks the wrong one — or your terminal supports a protocol that isn't in the table — pin it in the project config instead of rebuilding to test:
batamanta: [show_banner: true, image_protocol: :kitty]
or for one build, without touching mix.exs:
BATAMANTA_IMAGE_PROTOCOL=iterm2 mix batamanta
The config value wins over the environment variable. An unrecognised value is a hard error rather than a silent downgrade, because a typo that fell back to text mode is indistinguishable from "this terminal can't do images" — the banner just quietly stops appearing.
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
erts_target |
atom | :auto |
Target platform (see below) |
otp_version |
string | :auto |
OTP version (e.g., "28.1") |
format |
atom | :release¹ |
:escript o :release |
execution_mode |
atom | :cli |
:cli, :tui, or :daemon |
compression |
integer | 3 |
Zstd compression level (1-19) |
binary_name |
string | app name | Custom binary name |
show_banner |
boolean | true |
Show build banner (an image, when the terminal supports it) |
image_protocol |
atom | :auto |
Force the banner image protocol: :kitty, :iterm2, :sixel, :ascii (see below) |
umbrella |
boolean | false |
Enable umbrella mode (see below) |
force_os |
string | nil | Force OS: "linux", "macos", "windows" |
force_arch |
string | nil | Force arch: "x86_64", "aarch64" |
force_libc |
string | nil | Force libc: "gnu", "musl" (Linux only) |
¹ Auto-detected as
:escriptwhen the project definesescript: [main_module: ...]inmix.exs.
3. Build
mix batamanta
This generates: my_app-0.1.0-x86_64-linux (or appropriate target)
ERTS Target System
Batamanta uses a unified ERTS target system for platform specification.
Supported Targets
| Target Atom | OS | Arch | Libc | Use Case |
|---|---|---|---|---|
:auto |
- | - | - | Auto-detect host (default) |
:ubuntu_22_04_x86_64 |
Linux | x86_64 | glibc | Debian, Ubuntu, Arch, CachyOS |
:ubuntu_22_04_arm64 |
Linux | aarch64 | glibc | ARM servers, Raspberry Pi 4 |
:alpine_3_19_x86_64 |
Linux | x86_64 | musl | Alpine Linux, containers |
:alpine_3_19_arm64 |
Linux | aarch64 | musl | Alpine on ARM |
:macos_12_x86_64 |
macOS | x86_64 | - | Intel Mac |
:macos_12_arm64 |
macOS | aarch64 | - | Apple Silicon (M1/M2/M3) |
:windows_x86_64 |
Windows | x86_64 | msvc | ✅ Supported |
Manual Override
Force a specific target regardless of host:
batamanta: [
erts_target: :alpine_3_19_x86_64, # Force Alpine musl
execution_mode: :cli
]
Or use individual overrides:
batamanta: [
force_os: "linux",
force_arch: "x86_64",
force_libc: "musl"
]
CLI Override
# Auto-detect (default)
mix batamanta
# Force specific target
mix batamanta --erts-target alpine_3_19_x86_64
# Force individual components
mix batamanta --force-os linux --force-arch aarch64 --force-libc musl
OTP Version Control
You specify, you own. If you specify otp_version, that exact version is used. If not specified, a conservative fallback is used.
Configuration
# Use exact OTP version (recommended for production)
batamanta: [
otp_version: "28.1"
]
Behavior
| Mode | Description | When to Use |
|---|---|---|
| Explicit | Uses exact version specified. Fails if not available in repository. | Production builds, reproducibility |
| Auto | Uses conservative fallback (28.0 → 28.1 → ...). Uses system ERTS if not found. | Development, quick builds |
CLI Override
# Specify exact OTP version
mix batamanta --otp-version 28.1
# Auto mode (default)
mix batamanta
Version Resolution
In auto mode, if the exact version is not available:
- Tries
OTP-28.0first (most common) - Then
OTP-28.1,OTP-28.2, etc. - Falls back to system ERTS if nothing found
Execution Modes
| Mode | Description | Terminal Mode | Platform |
|---|---|---|---|
:cli |
Standard CLI with inherited stdin/stdout/stderr | Cooked (canonical mode, line-buffered input) | All |
:tui |
Text UI with raw terminal mode, arrow key navigation | Raw (direct character input, no line buffering) | Unix only |
:daemon |
Runs in background, no terminal I/O | N/A (detached) | Unix only |
Note: In
:climode (cooked), the terminal processes input line-by-line - Enter sends the line, backspace works normally, and special keys like arrow keys are not directly captured. In:tuimode (raw), the app has direct control over the terminal and can capture individual keypresses including arrow keys, function keys, etc.
BEAM Daemon Mode (Unix)
🦇 New in 2.0: every invocation of your
batamantabinary used to pay the fullmix release+cargo build+ Erlang boot cost — about 10-25 seconds per call. Daemon mode wraps a persistent Erlang VM behind the binary so subsequent invocations hit a warm, already-booted BEAM in ~5-15 ms via Unix-domain socket.
What it does
The first time you run your binary, the Rust wrapper extracts the payload and spawns a long-lived BEAM process that:
- Binds a Unix-domain socket under
$XDG_RUNTIME_DIR/batamanta/(or/tmpfallback) namespaced by<app>-<version>-<target>. - Boots your app and parks on a receive loop.
- Writes a PID file alongside the socket.
Subsequent invocations:
- Connect to the existing socket
- Send a length-prefixed JSON request (
<u32 length><json payload>) with the user's CLI args + environment - Wait for the matching JSON response
- Exit
The BEAM keeps running between calls. After default_ttl_ms of inactivity
(default 30s) it shuts itself down. You can also send a batamanta_daemon_kill
arg to ask it to exit cleanly.
Performance
| Path | Cold call | Warm call (1-1000+) |
|---|---|---|
Legacy single-shot (:cli/:tui/:escript) |
10-25s | 10-25s (no caching) |
Daemon mode (:daemon) |
10-25s (first call) | 5-15 ms (socket dispatch) |
This is a ~50-300× speedup for the warm path. Most useful in CI pipelines, batch jobs, and scripts that invoke the same binary many times in a row.
Configuration
Add to your mix.exs:
def project do
[
# ...
batamanta: [
execution_mode: :cli, # your CLI module is invoked per call
daemon: [
enabled: true, # turn on BEAM-keeps-alive
default_ttl_ms: 30_000, # daemon self-shuts after 30s idle
request_timeout_ms: 60_000 # per-request upper bound
]
]
]
end
The execution_mode field controls which entry point the daemon dispatches
to — typically :cli (it forwards the request to YourApp.CLI.main/1).
:daemon mode is also valid for long-running service binaries.
Manual daemon control
| Action | What to run |
|---|---|
| Force-shutdown the daemon for the current app/version/target | <binary> batamanta_daemon_kill |
| Override TTL for a single invocation | <binary> --batamanta-daemon-ttl-ms 5000 … (sends 5s TTL with this request) |
| Disable daemon for one call | <binary> --batamanta-daemon-disable … (falls back to legacy) |
| Bypass daemon entirely (e.g. fresh boot) | <binary> --batamanta-daemon-bootstrap … (always cold-start) |
Platform support
| Platform | Status |
|---|---|
| Linux (glibc/musl) | ✅ Full daemon mode |
| macOS (aarch64, x86_64) | ✅ Full daemon mode |
| Windows | ⚠️ Compiles, falls back to legacy single-shot. The wrapper prints daemon mode is Unix-only; falling back to legacy single-shot and exits non-zero. A uds-backed Windows port is tracked in the post-2.0 backlog. |
How dispatch works
+------------------------+
$ ./mybin ... | Rust wrapper |
──────────► | (compiled C, ~600kB) |
| |
| 1. If BATAMANTA_BEAM_ |
| ALIVE=1 → connect |
| to $XDG_RUNTIME_ |
| DIR/batamanta/ |
| <app>-<v>-<t>.sock |
| |
| 2. Send JSON request |
| over UDS: |
| <u32 length> |
| <utf-8 JSON body> |
| |
| 3. Read framed reply |
| & exit with rc. |
+──────────┬─────────────+
│
▼
+─────────────────────────────+
│ BEAM daemon (long-lived) │
│ │
│ • YourApp.CLI.main(args) │
│ • YourApp.Application │
│ • Persistence (ets/dets) │
│ • Process registry │
│ │
│ Park on receive; shut │
│ down after TTL idle. │
└─────────────────────────────┘
Reset & cleanup
The daemon's runtime files are under:
$XDG_RUNTIME_DIR/batamanta/<app>-<version>-<target>.sock$XDG_RUNTIME_DIR/batamanta/<app>-<version>-<target>.pid
These are cleaned up automatically when the daemon exits (TTL or kill arg). On hard kill, the next invocation of the same binary detects the stale PID file and bootstraps a fresh daemon.
To wipe everything: rm -rf "${XDG_RUNTIME_DIR:-/tmp}/batamanta".
Source of truth
- Spec:
attachments/faf7f99e756c9e7b/batamanta-daemon-mode-spec.md - Protocol: 4-byte big-endian length prefix + JSON
- Build hash: SHA-256 of the payload, first 6 bytes hex (12 chars)
- TTL cap: 86_400_000 ms (24h), validated client-side
Output Formats
| Format | Description | Notes |
|---|---|---|
:release |
Full OTP release with ERTS (default) | Larger (~60-70MB), self-contained |
:escript |
Lightweight escript bundle with minified ERTS | Smaller (~20MB), self-contained |
Umbrella Projects
Batamanta supports Elixir umbrella projects. Set umbrella: true at the umbrella root to package only the sub-apps that have batamanta: configured in their individual mix.exs:
# umbrella_root/mix.exs
def project do
[
apps_path: "apps",
deps: deps(),
batamanta: [
umbrella: true,
show_banner: true
]
]
end
# umbrella_root/apps/my_service/mix.exs
def project do
[
app: :my_service,
version: "0.1.0",
batamanta: [
format: :release,
binary_name: "my_service"
],
deps: deps()
]
end
# umbrella_root/apps/my_cli/mix.exs
def project do
[
app: :my_cli,
version: "0.1.0",
batamanta: [
format: :escript
],
escript: [main_module: MyCli.CLI],
deps: deps()
]
end
When you run mix batamanta at the umbrella root, batamanta:
- Detects all sub-apps in
apps/that havebatamanta:config - Builds releases for all apps once (
mix releaseis umbrella-aware) - Packages only the configured apps into standalone binaries
- Each app uses its own
format,binary_name,compression, andexecution_mode
Sub-apps without batamanta: config are ignored. The umbrella root config provides shared settings (ERTS target, OTP version) while each sub-app overrides individual settings.
Compatibility Matrix
Operating Systems
| OS | Architectures | Modes | Status |
|---|---|---|---|
| macOS 11+ | x86_64, aarch64 | CLI, TUI, Daemon | ✅ Full Support |
| Linux (glibc) | x86_64, aarch64 | CLI, TUI, Daemon | ✅ Full Support |
| Linux (musl) | x86_64, aarch64 | CLI, Daemon | ✅ Supported |
| Windows 10+ | x86_64 | CLI | ✅ Supported |
Windows note: binaries boot exclusively from the bundled ERTS — system Erlang is never consulted. At runtime they only need Git for Windows (bash) to interpret the
.runlauncher.
OTP / Elixir Versions
| OTP | Elixir | Status |
|---|---|---|
| 25 | 1.15 | ✅ Minimum Supported |
| 26 | 1.15, 1.16 | ✅ Supported |
| 27 | 1.15, 1.16, 1.17 | ✅ Supported |
| 28 | 1.16, 1.17, 1.18+ | ✅ Latest |
Restrictions
- ❌ Windows + TUI mode (requires Unix terminal)
- ❌ Windows + Daemon mode (requires Unix process management)
- ❌ OTP < 25 (missing required BEAM features)
- ❌ Elixir < 1.15 (missing required language features)
Troubleshooting: Linux musl/glibc
Problem: "libc mismatch detected" Warning
If you see a warning like:
⚠️ libc mismatch detected!
Expected: glibc (Debian/Ubuntu/Arch/Fedora)
Detected: musl libc (Alpine)
This means your system's libc type doesn't match the expected ERTS target.
Solution 1: Let Batamanta auto-detect (recommended)
batamanta: [
erts_target: :auto # Auto-detects musl vs glibc
]
Solution 2: Force specific target
batamanta: [
erts_target: :alpine_3_19_x86_64 # Force musl
]
Solution 3: Use CLI override
mix batamanta --erts-target alpine_3_19_x86_64
Problem: ERTS download fails on Alpine/musl
If ERTS download fails with 404 error on musl systems, try one of these solutions:
Solution 1: Use auto-detection (recommended)
batamanta: [
erts_target: :auto # Auto-detects musl vs glibc
]
Solution 2: Use a specific OTP version
batamanta: [
otp_version: "28.0" # Try an older version that may have musl builds
]
Solution 3: Build custom ERTS for musl (advanced)
# On Alpine Linux
apk add erlang-dev
cd /tmp
git clone https://github.com/erlang/otp.git
cd otp
./otp_build autoconf
./configure --prefix=/usr/local
make
make install
tar -czf musl-erts.tar.gz /usr/local/lib/erlang
Problem: Binary doesn't run on target system
If the binary works on build machine but fails on target:
Check libc compatibility:
# On build machine
ldd --version
# On target machine
ldd --version
# They should match (both glibc or both musl)
Solution: Build for oldest supported glibc version
# Use Ubuntu 22.04 target (most compatible glibc)
batamanta: [
erts_target: :ubuntu_22_04_x86_64
]
Problem: Cross-compilation from macOS to Linux
Install Rust targets:
rustup target add x86_64-unknown-linux-gnu
rustup target add aarch64-unknown-linux-gnu
Build with explicit target:
mix batamanta --erts-target ubuntu_22_04_x86_64
How libc Detection Works
Batamanta uses multiple methods in order:
ldd --version- Most reliable, checks output for "musl" or "glibc"- Dynamic loader files - Checks
/lib/ld-musl-*.sovs/lib64/ld-linux-*.so /etc/os-release- ChecksID=alpine,ID=void, etc./proc/self/maps- Advanced, checks loaded libraries
Detection always falls back to glibc if uncertain (90%+ of systems use glibc).
ERTS Download Fallback
Batamanta attempts to download pre-compiled ERTS from Hex.pm builds. If the download fails:
⚠️ Could not download ERTS, using system ERTS instead.
The build continues using the system ERTS (similar to Bakeware). This means:
- ✅ Build succeeds - Your application compiles
- ⚠️ Binary requires ERTS - Target machine needs compatible Erlang/Elixir
- ✅ Portable within same OS - Works on machines with same libc type
For production self-contained binaries:
- Ensure network access during build
- Use specific ERTS version:
batamanta: [otp_version: "26.2.5"] - Ensure the target platform has pre-built ERTS available
CLI Options
Override configuration via command line:
# Use auto-detection (default)
mix batamanta
# Force ERTS target
mix batamanta --erts-target alpine_3_19_x86_64
# Force individual components
mix batamanta --force-os linux --force-arch aarch64 --force-libc musl
# Force escript format
mix batamanta --format escript
# Adjust compression level
mix batamanta --compression 9
# Combine options
mix batamanta --erts-target ubuntu_22_04_arm64 --compression 5
Available CLI Flags
| Flag | Description |
|---|---|
--format |
Output format: release or escript |
--erts-target |
Override ERTS target atom |
--otp-version |
Specify exact OTP version (e.g., "28.1") |
--force-os |
Force OS: linux, macos, windows |
--force-arch |
Force architecture: x86_64, aarch64 |
--force-libc |
Force libc: gnu, musl (Linux only) |
--compression |
Zstd compression level (1-19) |
For CLI Applications
Use Erlang's :init to read arguments:
defmodule MyApp do
use Application
@impl true
def start(_type, _args) do
args =
:init.get_plain_arguments()
|> Enum.map(&to_string/1)
|> Enum.reject(&(&1 == "--"))
case args do
["hello", name] -> IO.puts("Hello, #{name}!")
_ -> IO.puts("Usage: my_app hello <name>")
end
System.halt(0)
end
end
Don't forget System.halt/1 when your CLI finishes!
How ERTS Provisioning Works
-
Auto-detection: Batamanta detects your host platform using:
:os.type()for OS identification:erlang.system_info(:system_architecture)for architectureldd --versionfor libc detection on Linux (glibc vs musl)
-
Download: Fetches pre-compiled ERTS from Hex.pm builds or from the Batamanta ERTS Repository
-
Cache: Stores in
~/.cache/batamanta/for reuse -
Package: Bundles your release + ERTS into a single compressed tarball
-
Compile: Rust dispenser embeds the payload and handles extraction at runtime
Build Environment Isolation
Batamanta version 1.4.0+ includes Batamanta.EnvCleaner, which automatically handles environment isolation during binary generation.
Why this matters
When using version managers like asdf, mise, or kerl, your shell's PATH points to shimmed versions of Erlang and Elixir. If these versions differ from the ERTS being embedded, you may encounter:
- "Corrupt atom table" crashes
- Inconsistent behavior between build-time and runtime
- Compilation failures in CI environments
How it works
When you run mix batamanta, the tool:
- Detects and filters out version manager paths from the
PATH. - Sanitizes the environment to include only essential system variables.
- (In Escript mode) Prepends the downloaded ERTS bin directory to the
PATHduring compilation, ensuring 100% version parity.
This mechanism ensures that the binary you build is exactly matched to the runtime environment it will use.
ERTS Repository
Batamanta uses a separate repository for pre-compiled ERTS binaries:
This repository hosts pre-compiled Erlang Run-Time System (ERTS) binaries for:
- macOS: aarch64 (Apple Silicon)
- Linux (glibc): x86_64 & aarch64
- Linux (musl): x86_64 & aarch64
The binaries are compiled from official Erlang/OTP sources and are subject to the Apache License 2.0 (see the repository for details).
Troubleshooting
Linux: "ERTS not found" or wrong ERTS downloaded
Batamanta auto-detects using ldd --version. If this fails:
# Check what ldd reports
ldd --version
# Force specific target
mix batamanta --erts-target ubuntu_22_04_x86_64
macOS: Binary doesn't run on older macOS versions
Ensure you're building with the correct deployment target:
batamanta: [
erts_target: :macos_12_x86_64 # or :macos_12_arm64
]
Cross-compilation from macOS to Linux
Install Rust targets:
rustup target add x86_64-unknown-linux-gnu
rustup target add aarch64-unknown-linux-gnu
Then build:
mix batamanta --erts-target ubuntu_22_04_x86_64
Alpine/musl: "Library not found"
Ensure musl development headers are installed:
# Alpine
apk add musl-dev
# Or use the Alpine Docker image
docker run --rm -v $(pwd):/app -w /app elixir:1.18-alpine ...
Architecture
- Detect: Auto-detect or resolve manual target configuration
- Fetch: Download ERTS from Hex.pm builds
- Release: Compile your Elixir code with
mix release - Package: Bundle release + ERTS with Zstd compression
- Compile: Build Rust dispenser that embeds the payload
- Run: Dispenser extracts payload and spawns Erlang VM
Escript Support
Batamanta can package projects that use mix escript.build as self-contained binaries:
def project do
[
app: :my_escript_app,
version: "0.1.0",
batamanta: [
format: :escript, # :escript or :release
escript_module: MyEscriptApp.CLI # Module with main/1 function
],
escript: [
main_module: MyEscriptApp.CLI
]
]
end
The project should have a module with a main/1 function:
defmodule MyEscriptApp.CLI do
def main(args) do
IO.puts("Escript running with args: #{inspect(args)}")
end
end
Note: The format: :escript in batamanta: is optional if your project already has escript: configuration in mix.exs - Batamanta auto-detects escript format. But you can include it explicitly for clarity.
Testing
Run the test matrix locally:
# Test across Linux distributions (requires Docker)
./docker_matrix.sh
# Run smoke tests manually
cd smoke_tests/test_cli && mix batamanta && ./test_cli-* arg1 arg2
cd smoke_tests/test_tui && mix batamanta && ./test_tui-*
cd smoke_tests/test_daemon && mix batamanta && ./test_daemon-* &
cd smoke_tests/test_escript && mix batamanta && ./test_escript --help
License
MIT