AM–0061736D

ampoule

Pack an Elixir or Erlang release into one executable. Download, pack, run.

What you ship

my_app-x86_64-linuxYour release, the BEAM compiled to WebAssembly, and the OS layer it runs on. The machine that runs it needs nothing else. ×1

For Erlang/OTP 28 · macOS and Linux · arm64 and x86_64 · Apache-2.0

  1. Download ampoule.

    $ curl -fsSL https://filipecabaco.github.io/ampoule/install.sh | sh

    It installs ampoule into ~/ampoule with the runtime for every platform, checks each file against the release’s checksums, and prints the line to add to your PATH. It needs no sudo and edits no shell profile. Read the script first, pin a version with AMPOULE_VERSION=0.1.12, or install by hand.

  2. Pack your project.

    In your Mix project:

    $ ampoule release --target x86_64-linux

    Without --target, it packs for the machine you are on. Settings such as running migrations before the app starts go in ampoule.toml next to mix.exs, before you pack (see Running it). Packing needs Elixir and Erlang/OTP 28; the machine that runs the file needs nothing.

  3. Copy the one file over. Run it.

    $ scp \
      _build/prod/ampoule/my_app-x86_64-linux \
      server:
    $ ssh server \
      DATABASE_URL=… \
      ./my_app-x86_64-linux serve

    Configure it with environment variables, as any release. serve starts the app, after the steps ampoule.toml lists first, such as migrations.

    That is the whole assembly.

What packing looks like

$ cd my_app
$ ampoule release
==> MIX_ENV=prod mix release --overwrite
==> my_app 0.1.0: 1018 files, 11.6 MiB → 4.6 MiB image (32 paths pruned)
_build/prod/ampoule/my_app-aarch64-darwin (11.8 MiB)
==> packed in 0.3s; run `<binary> help` for its commands

$ _build/prod/ampoule/my_app-aarch64-darwin start
[info] Running MyApp with Bandit 1.11.1 at 0.0.0.0:4000 (http)
Real output, examples/francis on macOS arm64. The file behaves like the release's own bin/my_app: start, eval, your own commands, configuration from environment variables, and a clean init:stop() on SIGTERM.

Before you start

Works

  • Phoenix, Ecto, Francis, SQLite, plain OTP: seven examples, each with an end-to-end suite.
  • NIFs in C, C++ or Rust, rebuilt for wasm when you pack. That needs wasi-sdk 34, and Rust NIFs also need rustup target add wasm32-wasip1-threads.
  • Host programs you allow: ports, System.cmd/3 and :os.cmd/1, with exit statuses, os_pid and {:packet, N} as on a native release.
  • Programs shipped inside the release, in priv/, with no allow list: they are extracted on first use and run on the host.
  • Unix domain sockets ({:local, path}) at the app’s own paths, host services included when you mount them in.
  • Erlang distribution with --node my_app@10.0.0.5: packed nodes cluster with each other and with native ones, on one known port or through the host’s epmd. remote and rpc reach a running node.
  • Elixir and Erlang/OTP 28 on the machine that packs. Nothing on the machine that runs.

Does not work

  • A program in priv/ built for another OS or CPU than the target: it cannot start there, and packing warns about it.
  • Starting epmd: without one on the host, every node of a cluster listens on the same distribution port, one node per address.
  • An OTP major other than 28: the engine runs ERTS 16.
  • A NIF thread that calls enif_thread_exit: it ends without cleanup and joining it never returns. Return from the thread function instead.

Good to know

When you need more

None of these is a step. Each stands on its own: take the ones your app needs.

Running it

Start it, call into it

The file takes the release script’s commands: start, eval, and any you add.

If the app migrates a database before it starts, put the order in ampoule.toml at the project root, before you pack; packing stores it in the file. A Phoenix app gets MyApp.Release from mix phx.gen.release.

$ ./my_app-aarch64-darwin start
$ ./my_app-aarch64-darwin eval 'IO.puts(MyApp.version())'
$ PORT=8080 ./my_app-aarch64-darwin serve
serve = ["migrate", "start"]

[commands]
migrate = "MyApp.Release.migrate()"

[start.env]
PHX_SERVER = "true"

Stop it

SIGTERM or Ctrl-C: the release shuts down through init:stop() and exits 0, as it would natively. A second signal, or an app still running after --stop-timeout, exits at once.

$ kill -TERM $PID
$ ./my_app-aarch64-darwin --stop-timeout 10 start

Cap its memory, restart it if it runs out

At 80% of the cap, OTP’s system_memory_high_watermark alarm goes up, so the app can shed load. If the VM still runs out, it starts again with its listening sockets held open. After five failures in five minutes it exits, for systemd or Kubernetes to take over.

$ ./my_app-aarch64-darwin --memory-max 512M \
    --restart on-failure start

Shipping it

In a container, alone

The file is the only thing the image needs.

FROM scratch
COPY my_app-aarch64-linux /my_app
ENTRYPOINT ["/my_app"]
CMD ["start"]

For every platform, from one machine

Each platform’s file starts from that platform’s runtime, ampoule-<target> from a release, kept next to ampoule. The installer in step 1 puts every platform’s runtime there, and packing downloads one that is missing from the same release; name each target to pack.

$ ampoule release --target aarch64-darwin \
    --target x86_64-linux

As a command-line tool

With main, the file is the CLI: every argument goes to your function, so users type my_cli pull o11y/x. The runtime’s own commands move behind --vm.

[[mounts]] shows the app the directory it runs in. With home = "host", it finds the user’s config under their own home, so nobody needs a wrapper script.

home = "host"
main = "MyCli.main"

[[mounts]]
host = "$PWD"
$ ./my_cli pull o11y/x
$ ./my_cli --vm info

Into an iPhone or iPad app

--target ios writes the release and ERTS as an xcframework for the app to link, with the runtime from the macOS archive. iOS runs no second process, so there is no file to pack for it.

$ ampoule release --target ios

Reaching the host

Start host programs

Ports, System.cmd/3 and :os.cmd/1 start the programs you list in ampoule.toml, and nothing else. Each runs on the host, and the app finds it at its usual path.

[process]
allow = ["uname", "/bin/sh"]
System.cmd("uname", ["-s"])
#=> {"Darwin\n", 0}

Bring your NIFs

C, C++ and Rust NIFs are rebuilt for wasm while it packs. The packing machine needs wasi-sdk; the machine that runs the file needs nothing.

$ ampoule release
==> building eiconv for wasm (priv/eiconv_nif.so)
==> linking the engine with 1 NIF(s): eiconv_nif

What is inside the file

1 2 3 4 5
  1. 1ampoule runtimeThe OS layer, in Rust on wasmtime: WASI, threads, sockets, host programs, signals, a mount table and /proc.
  2. 2Release imageYour release, one compressed frame per file.
  3. 3Engine with NIFsDashed: only there when the app has NIFs. The BEAM relinked with them and precompiled for the target.
  4. 4ManifestYour ampoule.toml: commands, serve order, environment defaults, network and process policy, distribution and VM settings.
  5. 5TrailerWhere each section starts.

Test report

Apple M5 Pro, 6 performance and 12 efficiency cores, 48 GB, macOS 26.7.

Speedup as schedulers are added One scheduler 1×, 2 schedulers 1.98×, 4 schedulers 3.45×, 6 schedulers 4.9×, 8 schedulers 6.3×, 12 schedulers 8.37×, 18 schedulers 10.7×. Close to linear across the six performance cores, then slower on the efficiency cores. 0 5× 10× 15× 1246 81218 schedulers, one per core performance cores efficiency cores perfectly linear 1 scheduler: 1× 2 schedulers: 1.98× 4 schedulers: 3.45× 6 schedulers: 4.9× 8 schedulers: 6.3× 12 schedulers: 8.37× 18 schedulers: 10.7× 10.7×
One job split into 36 tasks of integer arithmetic, timed at each scheduler count. Every scheduler is a host thread: the speedup follows the cores, close to linear on the six performance cores.
Measured results on the machine above
The job above, 18 schedulers against 110.7×
CPU-bound Erlang against native Erlang with its JIT1.1–6× slower integer loops 1.1×, sorting lists 2.3×, folding a long list 6×
From launch to first HTTP response, examples/francis285 ms native release: 380 ms
HTTP requests per second, 50 keep-alive connections45–58k native release: 80–87k
Memory after that load137–141 MB native release: 170–176 MB
Idle memory of a small app, with 1 and with 18 schedulers69 / 94 MB
Smallest --memory-max that boots, 1 and 18 schedulers80 / 128 MiB
Starting a host program, System.cmd/319 ms 20 at once: 57 ms
First restart after running out of memory, with --restart1 s then 2, 4, 8, 16 s

Specifications

Specifications
Binary size, examples/francis, macOS arm6411.8 MiB
Packing time, after mix release0.3 s
docker stop, FROM scratch container1 s, clean
Supabase Realtime, its own end-to-end suite53 / 53
The seven examples' end-to-end suites48 / 48
Targetsaarch64-darwin, x86_64-darwin, aarch64-linux, x86_64-linux
EngineERTS 16.4.0.4 from Erlang/OTP 28.5.0.4, wasm32-wasip1-threads
Versionv0.1.13
LicenseApache-2.0