This guide walks through setting up precompiled Zig NIFs for your Elixir library.
Why precompile?
Zigler provides easy Zig NIFs, but every user needs the Zig compiler installed. Precompilation removes that requirement — users download a pre-built shared library that matches their platform.
Advantages over Rust precompilation
Zig's cross-compilation story is dramatically simpler than Rust's:
- No
crosstool or Docker — Zig can cross-compile to any target from any host - Single CI job — a matrix of
-Dtarget=flags builds everything - No NIF version matrix — Zig compiles directly against
erl_nif.h, so there's no separate NIF 2.15/2.16/2.17 dimension
Configure GitHub Actions
Enable read/write permissions for the repository:
- Settings → Actions → General
- Workflow permissions → Read and write permissions
Build workflow
name: Build precompiled NIFs
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- { target: aarch64-linux-gnu, os: ubuntu-latest }
- { target: aarch64-linux-musl, os: ubuntu-latest }
- { target: aarch64-macos-none, os: macos-latest }
- { target: arm-linux-gnueabihf, os: ubuntu-latest }
- { target: arm-linux-musleabihf, os: ubuntu-latest }
- { target: riscv64-linux-gnu, os: ubuntu-latest }
- { target: x86_64-linux-gnu, os: ubuntu-latest }
- { target: x86_64-linux-musl, os: ubuntu-latest }
- { target: x86_64-macos-none, os: macos-latest }
- { target: x86_64-windows-gnu, os: ubuntu-latest }
- { target: x86-linux-gnu, os: ubuntu-latest }
- { target: x86-windows-gnu, os: ubuntu-latest }
steps:
- uses: actions/checkout@v4
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.15.1
- name: Install Erlang/Elixir
uses: erlef/setup-beam@v1
with:
otp-version: '27'
elixir-version: '1.17'
- name: Build NIF
run: |
mix deps.get
mix zig.get
# Build for the target
ZIGLER_PRECOMPILING=${{ matrix.target }} mix compile
- name: Package artifact
run: |
VERSION=${GITHUB_REF#refs/tags/v}
BASENAME="MyApp.Native"
LIB_NAME="${BASENAME}-v${VERSION}-${{ matrix.target }}"
# Find the built library
if [[ "${{ matrix.target }}" == *windows* ]]; then
EXT=".dll"
else
EXT=".so"
fi
mkdir -p artifacts
cp priv/lib/*${EXT} "artifacts/"
cd artifacts
tar czf "${LIB_NAME}${EXT}.tar.gz" *${EXT}
- name: Upload to release
uses: softprops/action-gh-release@v1
with:
files: artifacts/*.tar.gzNote: The exact build script depends on your project structure. The key point is that Zig handles all cross-compilation natively — no Docker or
crossneeded.
The NIF module
defmodule MyApp.Native do
version = Mix.Project.config()[:version]
use ZiglerPrecompiled,
otp_app: :my_app,
base_url: "https://github.com/me/my_project/releases/download/v#{version}",
version: version,
force_build: System.get_env("MY_APP_BUILD") in ["1", "true"],
nifs: [
add: 2,
multiply: 2
]
endThe :nifs option is required. It declares every NIF function as either a
{name, arity} pair or a keyword entry containing :arity.
ZiglerPrecompiled generates stub functions matching Zigler's
marshalled-<name> convention — these stubs are replaced when the precompiled
.so loads.
Per-NIF Zigler options are forwarded when force_build: true. For example, a
long-running typed NIF can use a dirty scheduler without affecting the
precompiled stub declaration:
nifs: [long_running: [arity: 1, concurrency: :dirty_cpu]]Raw Zigler NIFs are not supported: Zigler uses :arity to declare the BEAM
arities of a raw function, while ZiglerPrecompiled uses it to generate a typed
stub.
When force_build: true, package-specific options are stripped and the
remaining options, including normalized :nifs, are passed to use Zig for
full compilation with rich error tracing and type marshalling wrappers.
Real-world example: QuickBEAM
defmodule QuickBEAM.Native do
version = Mix.Project.config()[:version]
use ZiglerPrecompiled,
otp_app: :quickbeam,
base_url: "https://github.com/dannote/quickbeam/releases/download/v#{version}",
version: version,
nifs: [
eval: 3, compile: 2, call_function: 4,
load_module: 3, load_bytecode: 2,
reset_runtime: 1, stop_runtime: 1, start_runtime: 2,
resolve_call: 3, reject_call: 3,
resolve_call_term: 3, reject_call_term: 3,
send_message: 2, define_global: 3,
memory_usage: 1,
dom_find: 2, dom_find_all: 2, dom_text: 2,
dom_attr: 3, dom_html: 1
]
endGenerating checksums
After CI uploads all artifacts:
mix zigler_precompiled.download MyApp.Native --all --print
This creates checksum-Elixir.MyApp.Native.exs. You must include it in your
Hex package:
defp package do
[
files: [
"lib",
"native",
"checksum-*.exs",
"mix.exs"
]
]
endRelease flow
- Tag a new release
- Push:
git push origin main --tags - Wait for all CI builds to finish
- Run
mix zigler_precompiled.download MyApp.Native --all - Publish to Hex (ensure
checksum-*.exsis infiles:)
Forcing a local build
Set the env var or config to skip precompiled downloads:
MY_APP_BUILD=1 mix compile
Or in config:
config :zigler_precompiled, :force_build, my_app: trueThis requires {:zigler, ">= 0.0.0", optional: true} in your deps.