Native executables that feel like Elixir modules.
Foundry makes working with native code (Rust, C/C++) just as ergonomic as writing Elixir. Just add use Foundry to a module, and it handles the build, tracks source changes for automatic recompilation, and generates path helper functions—all during mix compile.
Perfect for spawning external tools as Erlang Ports, whether you're wrapping a Rust CLI, a C++ image processor, or any standalone executable. No Rust experience required—if you can add a dependency, you can use Foundry.
- 🦀 Cargo support — Build Rust projects
- 🔧 CMake support — Build C/C++ projects
- ♻️ Smart recompilation — Tracks source files and only rebuilds when they change (not on every
mix compile) - 📍 Path helpers — Generated functions to locate your binaries
- 🔌 Zero configuration — Sensible defaults, just add
use Foundry
Add foundry to your dependencies in mix.exs:
defdepsdo[{:foundry,"~> 0.1.0"}]end- Create a Rust project in
native/:
my_app/
├── lib/
│ └── my_app/
│ └── native.ex
├── native/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs
└── mix.exs
- Add a Native module:
# lib/my_app/native.exdefmoduleMyApp.NativedouseFoundry,otp_app: :my_app,builder: :cargo,binaries: ["my_binary"]endRun
mix compile— your binary is now inpriv/!Use the generated path helpers:
# Generic lookupMyApp.Native.bin_path("my_binary")#=> "/path/to/_build/dev/lib/my_app/priv/my_binary"# Generated convenience functionMyApp.Native.my_binary_path()#=> "/path/to/_build/dev/lib/my_app/priv/my_binary"defmoduleMyApp.NativedouseFoundry,otp_app: :my_app,builder: :cmake,binaries: ["my_tool"],source_path: "c_src"# default for cmakeendThe typical use case is spawning the binary as an Erlang Port:
defmoduleMyApp.Runnerdodefstartdoexe=MyApp.Native.my_binary_path()Port.open({:spawn_executable,exe},[:binary,:exit_status,args: ["--some-flag","value"]])endend| Option | Type | Default | Description |
|---|---|---|---|
:otp_app | atom | required | Your application name |
:builder | atom | required | Build system (:cargo, :cmake, or custom module) |
:binaries | [String.t()] | required | List of binary names to copy |
:source_path | String.t() | "native" (cargo) / "c_src" (cmake) | Path to native source |
:profile | String.t() | Based on MIX_ENV | Build profile (any string) |
:skip_compilation? | boolean | false | Skip build, only copy |
:env | [{String.t(), String.t()}] | [] | Environment variables for build |
:builder_opts | keyword() | [] | Builder-specific options (see below) |
Pass these in builder_opts when using builder: :cargo:
| Option | Type | Default | Description |
|---|---|---|---|
:target | String.t() | nil | Rust target triple (cross-compile) |
:cargo | :system | {:bin, path} | :system | Cargo binary to use |
:target_dir | String.t() | _build/<env>/native/<app>/target | Cargo output directory |
Pass these in builder_opts when using builder: :cmake:
| Option | Type | Default | Description |
|---|---|---|---|
:target | String.t() | First binary | CMake target name to build |
:args | [String.t()] | [] | Extra CMake arguments |
:build_dir | String.t() | _build/<env>/native/<app>/build | CMake build directory |
You can implement your own builder by creating a module that implements the Foundry.Builder behaviour:
defmoduleMyApp.MakeBuilderdo@behaviourFoundry.Builder@impltruedefdefault_source_path,do: "c_src"@impltruedefvalidate_opts!(_opts),do: :ok@impltruedefbuild!(source_path,profile,opts)doenv=Keyword.get(opts,:env,[]){_,0}=System.cmd("make",[profile],cd: source_path,env: env):okend@impltruedefbinary_paths(source_path,binaries,_profile,_opts)doMap.new(binaries,fnname->{name,Path.join([source_path,"bin",name])}end)end@impltruedefdiscover_resources(source_path)doPath.wildcard(Path.join(source_path,"**/*.{c,h}"))endendThen use it:
defmoduleMyApp.NativedouseFoundry,otp_app: :my_app,builder: MyApp.MakeBuilder,binaries: ["my_tool"]endWhen you use Foundry, the macro:
- Runs the build at compile time via
cargo buildorcmake - Copies binaries from the build output to
_build/<env>/lib/<app>/priv/ - Registers external resources using
@external_resourcefor automatic recompilation - Generates helper functions for runtime path lookup
Foundry watches your source files:
Cargo projects:
Cargo.toml,Cargo.lock- All
*.rsfiles (excludingtarget/)
CMake projects:
CMakeLists.txt,CMakePresets.json- All
src/**/*.{c,cpp,h,hpp}files
When any of these change, Mix knows to recompile your Native module, triggering a fresh native build.
defmoduleMyApp.NativedouseFoundry,otp_app: :my_app,builder: :cargo,binaries: ["my_binary"],builder_opts: [target: "aarch64-unknown-linux-gnu"]enddefmoduleMyApp.NativedouseFoundry,otp_app: :my_app,builder: :cargo,binaries: ["my_binary"],builder_opts: [cargo: {:bin,"/usr/local/bin/cargo"}]enddefmoduleMyApp.NativedouseFoundry,otp_app: :my_app,builder: :cmake,binaries: ["my_tool"],builder_opts: [args: ["-DENABLE_FEATURE=ON","-DCUSTOM_VAR=value"]]endPass environment variables to the build process via the :env option:
defmoduleMyApp.NativedouseFoundry,otp_app: :my_app,builder: :cmake,binaries: ["my_nif"],env: [{"CC","clang"},{"CXX","clang++"},{"ERL_EI_INCLUDE_DIR","/usr/lib/erlang/usr/include"},{"ERL_EI_LIB_DIR","/usr/lib/erlang/usr/lib"}]endFoundry automatically uses release builds when MIX_ENV=prod:
MIX_ENV=prod mix compileOr override explicitly:
useFoundry,otp_app: :my_app,builder: :cargo,binaries: ["my_binary"],profile: "release"The :profile option accepts any string, so you can use custom Cargo profiles or CMake build types:
# Cargo custom profile (defined in Cargo.toml)useFoundry,otp_app: :my_app,builder: :cargo,binaries: ["my_binary"],profile: "release-lto"# CMake RelWithDebInfouseFoundry,otp_app: :my_app,builder: :cmake,binaries: ["my_tool"],profile: "RelWithDebInfo"defmoduleMyApp.NativedouseFoundry,otp_app: :my_app,builder: :cargo,binaries: ["tool_a","tool_b","tool_c"]end# Generated functions:MyApp.Native.tool_a_path()MyApp.Native.tool_b_path()MyApp.Native.tool_c_path()| Tool | Use Case | Languages | Tracks Source Changes | Path Helpers | Config Required |
|---|---|---|---|---|---|
| Foundry | Standalone executables as Ports | Rust, C/C++, extensible | ✅ | ✅ Generated functions | Minimal—just use Foundry |
| Rustler | Rust NIFs (in-process) | Rust only | ✅ | ✅ | Minimal |
| elixir_make | Run make during compile | Any (manual) | ❌ | ❌ | Manual Makefile + hooks |
| bundlex | Membrane framework NIFs | C/C++ | ❌ | ✅ | Moderate |
If you need to spawn executables as Ports (not NIFs), Foundry gives you Rustler-level DX:
- Rebuilds only when needed — Foundry tracks your native source files (
.rs,.c,Cargo.toml, etc.) and only triggers a rebuild when they change. No wasted time on everymix compile. - Generated path helpers — Get type-safe functions to locate your binaries at runtime. No manual path concatenation.
- Zero-config defaults — Just
use Foundry. No Makefiles, no manual dependency tracking, no custom Mix tasks.
Unlike elixir_make, you don't write scripts to track dependencies or find compiled binaries. Unlike always-rebuilding tools, you don't wait for unnecessary recompilation.
MIT License - see LICENSE for details.