Packaging an Elixir App for Zed Deploy
A walkthrough for the moment after mix release finishes successfully.
You have a release tarball; mainstream advice tells you to put it in a
Docker image, push to a registry, and let kubernetes pull it. This
guide takes a different turn: you’ll mount the tarball directly with
tarfs(5), declare the deployment as data, and let zfs rollback be
your undo button.
The concrete artifact throughout is the eXMC trader — a multi- account Bayesian trading process that runs continuously and survives both code upgrades and host reboots without losing its inflight state. But everything below applies to any Elixir release: a Phoenix app, a Broadway pipeline, a GenServer-heavy library.
Why this exists
You can ship an Elixir release in several ways. The mainstream answer in 2026 is Docker → registry → kubernetes / Fly.io / Render. The mainstream answer works. This guide isn’t trying to dethrone it.
What it offers, instead, is a path for the case where:
- You have a FreeBSD host (or a few of them) and don’t want a kubernetes control plane on top.
- You want state — version, deploy time, instance config — to live with the artifact on disk, not in an external store that can drift.
-
You want rollback to be a single
zfs rollbackcall, not akubectl rollout undothat triggers a re-pull from a registry. - You want the deployment to be spec-shaped Elixir code, not YAML.
The substrate is tarfs(5) (FreeBSD 14+, kernel-mounted POSIX tar) +
ZFS user properties + a small Elixir DSL called Zed. By the end of
this guide you’ll have:
- A trader release built locally.
-
The release tarred for
tarfs(5)consumption. -
A
MyDeploymodule that declares the deployment. -
MyDeploy.converge/0reconciling a real host to match the spec. -
A
MyDeploy.rollback/1call that undoes a bad version in onezfs rollback.
Prerequisites
-
A FreeBSD 14 or 15 host with a ZFS pool. The smallest viable setup is
zpool create tank /dev/da1on a spare disk. The host needs SSH access anddoas(orsudo) for the user that runs zed. -
doas.confconfigured for nopass escalation by the zed user. Zed’s converge shells out tomount,zfs, etc. viadoas. If any rule belowpermit nopass :wheelin/usr/local/etc/doas.confmatches the zed user (e.g.permit persist :wheel), doas will ask for a password — and since the zed agent runs without a tty, it hangs until SIGTERM. doas matches the LAST rule, so put thenopassrule at the end:permit persist :wheel permit nopass :wheel # ← must be last for the zed userOr scope tighter with
permit nopass :wheel cmd mountetc., asdocs/doas.conf.zedopsshows. -
The Zed repo cloned and built:
git clone … && mix deps.get && mix compile. -
Your Elixir app builds an OTP release via
mix release. If it doesn’t yet, the simplestreleasesblock is six lines:defp releases do [ exmc: [ include_executables_for: [:unix], applications: [runtime_tools: :permanent], strip_beams: Mix.env() == :prod ] ] end
This guide assumes you’re building the release on a build host (Mac or Linux) and deploying to a FreeBSD target. If you’re building directly on the target, the cross-compile step in §1 collapses.
§1 — Build the release
cd /path/to/your/app
MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix release exmc
This produces _build/prod/rel/exmc/:
_build/prod/rel/exmc/
├── bin/
│ ├── exmc # release control script
│ └── exmc.bat
├── erts-15.2.7.2/ # vendored Erlang runtime
├── lib/ # all dependency .beam files
├── releases/
│ └── 1.0.0/
│ ├── sys.config
│ ├── vm.args
│ └── env.sh
└── tmp/
About 30–50 MB for a typical Phoenix or Broadway app. For eXMC trader (includes Nx + nx_vulkan + spirit C++ NIF) the tree is ~80 MB.
The release is self-contained. The host doesn’t need Erlang
installed; the vendored erts- runtime ships in the tree.
§2 — Tar for tarfs(5)
The next step is what makes this different from Docker. Instead of copying the directory tree to the target and starting it from there, you produce a single uncompressed tar file that the kernel mounts as a filesystem:
cd _build/prod/rel
tar cf /tmp/exmc-1.0.0.tar exmc/
ls -lh /tmp/exmc-1.0.0.tar
# -rw-r--r-- 1 io io 82M May 22 18:30 /tmp/exmc-1.0.0.tar
Notes:
-
Uncompressed.
tarfs(5)needs raw POSIX tar, not gzipped. Usetar cf, nottar czf. -
No top-level extras. The tarball’s root is the release tree.
When mounted at
/opt/exmc, the layout becomes/opt/exmc/bin/exmc,/opt/exmc/lib/..., etc. -
Reproducible. Same release input + same
tarinvocation = byte-identical output. (Use GNU tar’s--mtimeflag if you need timestamps stamped for bit-for-bit reproducibility.)
Copy the tar to the target:
scp /tmp/exmc-1.0.0.tar root@deploy-host:/var/zed/artifacts/
Zed expects artifacts at /var/zed/<deploy>/artifacts/ by default.
That directory itself lives on a ZFS dataset Zed will manage.
§3 — The zed deployment spec
Now the Elixir part. Write a module that uses Zed.DSL:
defmodule TraderDeploy do
use Zed.DSL
deploy :exmc, pool: "tank" do
# ZFS dataset where the tar artifact lives
dataset "exmc/artifacts" do
compression :lz4
end
# tarfs(5) mount: the .tar from §2 becomes a read-only
# filesystem at /opt/exmc
tarfs :exmc_release do
tar_path "/var/zed/exmc/artifacts/exmc-1.0.0.tar"
mount "/opt/exmc"
end
# The app itself — a BEAM release with a known entry point
app :exmc do
release_root "/opt/exmc"
version "1.0.0"
node_name :"trader@localhost"
cookie {:env, "EXMC_COOKIE"}
end
# Health probes — Zed Phase 2.5 runs these after converge,
# rolls back if any host fails them. :beam_ping verifies the
# node is up and responding to distributed-Erlang traffic.
health :exmc do
probe :beam_ping, target: "trader@localhost", timeout: 5_000
retries 3
backoff 1_000
end
# Start the release as a long-running service. Zed will run
# `bin/exmc daemon` after the artifact is mounted and health
# gates have passed.
service_run :exmc_daemon do
command "/opt/exmc/bin/exmc daemon"
depends_on [:exmc_release, :exmc]
env %{
"EXMC_COOKIE" => "secret-cookie",
"EXMC_VULKAN_BACKEND" => "vulkano",
"ACCOUNTS_CONFIG" => "/var/zed/exmc/config/accounts.config"
}
end
snapshots do
before_deploy true
keep 5
end
end
end
Twenty-five lines of DSL. Each verb maps to a thing the converge loop will create, mount, start, or check.
The app :exmc entry doesn’t install the release — tarfs already
did that by mounting /opt/exmc. The app declaration tells Zed
the version + how to talk to the node for health checks and rollback.
Both version and dataset (or pool_path) are required. They
drive Zed.Converge.Executor.stamp_app_properties/3, which writes
com.zed:version, com.zed:app, com.zed:deployed_at,
com.zed:node_name, and com.zed:deployed_by onto the named
dataset. Omitting either causes the executor to silently treat
pool_path as nil and skip the entire stamping step — the
converge still returns :ok, but status/0 will show empty
metadata and snapshots get named zed-deploy-unknown-*. Learned
the hard way on the mac-247 walkthrough run; documented here so
the next reader doesn’t repeat it.
§4 — First converge
# In an iex session on the target (or via remsh)
TraderDeploy.diff()
This walks the spec, compares against the live host, and returns a list of changes. On a fresh box you’ll see something like:
[Zed.Converge.Diff] tank/exmc/artifacts (create) compression=lz4
[Zed.Converge.Diff] tarfs :exmc_release (mount) /opt/exmc
[Zed.Converge.Diff] :exmc (install) version=1.0.0
[Zed.Converge.Diff] health :exmc (set) :beam_ping
[Zed.Converge.Diff] service_run :exmc_daemon (start)
[Zed.Converge.Diff] snapshot policy (set) keep=5 before_deploy=true
diff/0 doesn’t touch the filesystem. Apply with:
TraderDeploy.converge()
Zed’s coordinated converge does this:
-
Snapshot the existing
tank/exmc/artifactsdataset (if any) — the rollback point if anything later fails. - Create the dataset if it doesn’t exist.
-
Mount
/var/zed/exmc/artifacts/exmc-1.0.0.tarat/opt/exmcviatarfs(5). One syscall; no extraction; mount latency is dominated by reading the central directory of the tar (~milliseconds for a 100 MB tar). -
Stamp ZFS properties on the dataset:
com.zed:version=1.0.0,com.zed:fingerprint=<sha256>,com.zed:deployed_at=<iso8601>. -
Start the service:
service_runexec’s/opt/exmc/bin/exmc daemonas a managed process. -
Health-check: probe
:beam_ping3 times with 1s backoff until the BEAM responds. If all retries fail across all hosts, Zed rolls back the snapshot from step 1 atomically. -
Phase 2.5 gate: Zed only declares the deploy successful after
the health probes pass. Spec:
specs/HealthCheck.tla— model-checked, withNoLatePromotionAfterRollbackinvariant covered.
Total wall time on a quiet host: ~3 seconds.
§5 — Verify and read back state
TraderDeploy.status()
%{
"tank/exmc/artifacts" => %{
version: "1.0.0",
fingerprint: "sha256:1f2a...",
deployed_at: ~U[2026-05-22 18:42:11Z],
mount: "/opt/exmc",
health: :ok,
service: %{name: :exmc_daemon, pid: 9842, uptime: 187}
}
}
status/0 doesn’t talk to your DSL module. It reads the live state:
ZFS properties from the dataset, mount status from mount(8), PID
and uptime from the service supervisor. Reality is the source of
truth — if someone runs zfs set com.zed:version=2.0.0 tank/exmc/artifacts
behind your back, status/0 reports the change.
§6 — Ship a version bump
Build version 1.0.1 of the trader:
# bump version in mix.exs, then:
MIX_ENV=prod mix release exmc
tar cf /tmp/exmc-1.0.1.tar -C _build/prod/rel exmc/
scp /tmp/exmc-1.0.1.tar root@deploy-host:/var/zed/exmc/artifacts/
Update the DSL spec — one character changes:
tarfs :exmc_release do
tar_path "/var/zed/exmc/artifacts/exmc-1.0.1.tar" # was 1.0.0
mount "/opt/exmc"
end
app :exmc do
release_root "/opt/exmc"
version "1.0.1" # was 1.0.0
# ... rest unchanged
end
Recompile, re-converge:
TraderDeploy.diff()
# [Zed.Converge.Diff] :exmc (update) 1.0.0 → 1.0.1
# [Zed.Converge.Diff] tarfs (remount) → /var/zed/exmc/artifacts/exmc-1.0.1.tar
TraderDeploy.converge()
Zed snapshots before the deploy (tank/exmc/artifacts@before-1.0.1),
unmounts the old tar, mounts the new tar at the same path, restarts
the service, runs health probes. If :beam_ping fails inside the
configured retries, the deploy auto-rolls back to 1.0.0 —
converge/0 returns {:error, :health_failed, ...} and the snapshot
is restored.
§7 — Manual rollback
Sometimes the deploy passes its health check and breaks an hour later (the new version has a subtle bug that takes time to surface). You can roll back manually:
TraderDeploy.rollback("@before-1.0.1")
One zfs rollback. The artifact dataset’s contents — the tar files,
the ZFS properties — all revert atomically to the snapshot. Zed
remounts /opt/exmc from the now-restored tar at the path that was
active at snapshot time. The service is restarted against the
restored release.
This is constant-time regardless of release size. A 1 MB release rolls back in the same wall time as a 1 GB release. ZFS snapshots are O(1) at create and at rollback; the bytes don’t move.
What’s different from Docker / k8s
| Concern | Docker / k8s | Zed |
|---|---|---|
| Artifact format | Layered OCI image | Uncompressed POSIX tar |
| Distribution | Registry push/pull |
scp (or zfs send) |
| Local extraction | Per-layer overlayfs at start |
None — tarfs(5) mounts the bytes in place |
| Boot time | Layer pulls + extract + start | Mount + start (~ms) |
| State of “what’s deployed” | etcd / control-plane | ZFS user properties on the dataset |
| Rollback |
kubectl rollout undo + re-pull |
zfs rollback (O(1)) |
| Health gate | k8s liveness/readiness |
Zed.Converge.Health + Phase 2.5 |
| Multi-host coordination | k8s controller |
Zed.Cluster.converge_coordinated/1 |
| Spec | YAML | Elixir DSL macros |
The substrate is different (FreeBSD + ZFS + tarfs vs Linux + cgroups
-
overlayfs) but the deployment model is functionally similar.
The DSL replaces YAML; ZFS replaces etcd; tarfs replaces overlayfs;
zfs rollbackreplaces image-tag pinning. Each substitution is defensible on its own; the bundle is what makes it interesting.
Where to go next
-
Multi-host deploys. Replace
deploy :exmc, pool: ...with acluster :prod do host :h1 ... endblock.converge_coordinated/1runs the same spec against N hosts in lock-step, two-phase, with health probes per host before any commit. The protocol is TLA+-verified (specs/HealthCheck.tla). -
Secrets. This guide hardcoded a cookie. Real deployments
should use a ZFS-managed secrets dataset with restricted
permissions. The secrets pipeline is the next big item on
zed’s roadmap; track it in
docs/. -
Production checklist. See
docs/dual-mac-runbook-results.mdfor a real two-host trader deploy that ran through this flow.
What you just built
A release artifact, in a tar, mounted via the kernel, declared by a
~25-line Elixir module, with health-checked atomic rollback. No
container runtime. No registry. No control plane. The state of
“what’s deployed” lives next to the artifact, in the same ZFS
property tree that zfs send | zfs receive replicates verbatim.
If you’ve used Docker, the model is recognisable: a build pipeline that produces an artifact, a manifest that declares where it should run, a control loop that reconciles intent to reality, and a rollback story that doesn’t depend on the build cache surviving. What Zed swaps out is the mechanism, not the model. The DSL maps one-for-one onto the verbs you already think in.