Zed in 10 Minutes
An onramp for Elixir developers who haven’t done much ops. By the end of
this guide you’ll have written a deployment spec, applied it, shipped a
version bump, and rolled back to the previous version — all using the
same mix-style ergonomics you already know, and a filesystem you may
not.
What Zed Is
Zed is a deployment tool for Elixir/BEAM applications. It lets you describe what should be running on a FreeBSD host — datasets, apps, jails, snapshots — and reconciles the host to match.
The interesting bit is where the state lives. Most deployment tools keep their own database of “what’s deployed” — etcd, an S3 bucket of Terraform state, a Chef server. Zed keeps it in ZFS user properties on the filesystem itself.
$ zfs get com.zed:version tank/apps/hello
NAME PROPERTY VALUE SOURCE
tank/apps/hello com.zed:version 1.0.1 local
That property travels with the dataset. zfs send | zfs receive
replicates it. zfs rollback reverts it together with the data, in O(1).
No separate state store can drift from reality, because reality is
the state store.
The Three Nouns
To deploy something with Zed you write a module that uses Zed.DSL.
Inside a deploy block you declare three kinds of things:
-
dataset— a ZFS dataset. Zed creates it with the properties you specify (compression, mountpoint, etc.) and stamps the metadata it needs onto it (com.zed:role, version, fingerprint). -
app— a BEAM release. Zed knows how to install a release into a dataset, start it under a release script, and tear it down on rollback. Each app has aversion, adatasetit lives in, and optionally acookieandnode_namefor clustering. -
snapshots— the retention policy.before_deploy truemeans Zed snapshots the app’s dataset before every converge.keep 5means it prunes anything older than the last five.
Convergence is the loop that turns a spec into reality:
diff → plan → apply → verify
↑ ↓
└────── if verify fails ───────┘
rollback
Each phase is pure data until apply. You can call diff/0 all day
without touching the filesystem.
Prereqs
Zed runs on FreeBSD with a ZFS pool. The smallest setup is a
FreeBSD 14 or 15 VM with a single pool — zpool create tank /dev/da1
on a spare disk gets you there. If you’re trying this from a Mac or
Linux box, a bhyve VM, an iohyve guest, or vmrun running FreeBSD all
work.
Inside that VM:
pkg install elixir erlang-runtime27 bastille
# Clone zed somewhere
git clone https://github.com/borodark/zed
cd zed && mix deps.get && mix compile
You won’t need to be a ZFS expert. The only command you’ll type by hand
is zpool status to confirm the pool exists, and (later) zfs list
to confirm Zed put the datasets where it said it would.
Your First Deploy
Create a file lib/hello_infra.ex in your own project, or anywhere
Zed can compile it:
defmodule HelloInfra do
use Zed.DSL
deploy :hello, pool: "tank" do
dataset "apps/hello" do
compression :lz4
end
app :hello do
dataset "apps/hello"
version "1.0.0"
node_name :"hello@localhost"
cookie {:env, "RELEASE_COOKIE"}
end
snapshots do
before_deploy true
keep 5
end
end
end
That’s about it. Twenty lines. The use Zed.DSL macro adds four
functions to HelloInfra: diff/0, converge/1, status/0, and
rollback/1.
Step 1 — see what would change
HelloInfra.diff()
This walks the spec and compares it against the pool. On a fresh box the output looks like:
[Zed.Converge.Diff] tank/apps/hello (create) compression=lz4
[Zed.Converge.Diff] :hello (install) version=1.0.0
[Zed.Converge.Diff] snapshot policy (set) keep=5 before_deploy=true
diff/0 returns a list of %Zed.Converge.Diff{} structs you can
pattern-match. It does not touch the filesystem.
Step 2 — apply
HelloInfra.converge()
Zed builds a plan from the diff, executes each step, and verifies the result. The phases:
-
zfs createfortank/apps/hello, withcompression=lz4. -
install the release (your
hello-1.0.0.tar.gzartifact lands in the mounted dataset). -
stamp ZFS properties —
com.zed:version=1.0.0,com.zed:fingerprint=<sha256>, etc. -
start the release script (
./bin/hello daemon). - verify — Zed reads back the properties and confirms they match.
If verify fails, Zed rolls back to the pre-deploy snapshot it took at
the start of step 1. That rollback is one zfs rollback. Atomic.
Step 3 — read state back
HelloInfra.status()
%{
"tank/apps/hello" => %{
version: "1.0.0",
fingerprint: "sha256:1f2a...",
deployed_at: ~U[2026-05-20 14:18:22Z]
}
}
status/0 doesn’t talk to your DSL module at all — it reads the
properties off the live datasets. If someone runs zfs set com.zed:version=2.0.0 tank/apps/hello behind your back, status/0
reports the change. Reality is the source of truth.
Step 4 — ship a version bump
Edit HelloInfra and change version "1.0.0" to version "1.0.1".
Recompile. Then:
HelloInfra.diff()
# [Zed.Converge.Diff] :hello (update) version 1.0.0 → 1.0.1
HelloInfra.converge()
Zed snapshots tank/apps/hello@before-1.0.1 first (the before_deploy
policy), then installs 1.0.1 over the top. Two snapshots now exist:
@before-1.0.0 and @before-1.0.1.
Step 5 — roll back
Say 1.0.1 crashes on startup. The deploy itself will have rolled back
automatically — converge/1 returns {:error, :verify_failed, _} and
the dataset is restored to 1.0.0. But suppose verify passed and the
crash happens an hour later. You roll back manually:
HelloInfra.rollback("@before-1.0.1")
That’s one zfs rollback. The dataset’s contents, the
com.zed:version property, and the file timestamps all revert
together. The 1.0.1 install simply ceases to have existed.
What You Just Did
You wrote a 20-line DSL module, ran four function calls against it, and walked through the full deploy / version-bump / rollback loop without configuring etcd, a state bucket, or anything that resembles a control plane. Two pieces did the heavy lifting:
-
The DSL is just Elixir macros. Your spec is a compile-time
artifact;
mix compilecatches typos inversion, missingdatasetreferences, malformed pool names. -
ZFS is the runtime. Properties hold the version, snapshots hold
the history,
zfs rollbackis the undo button. Zed is the layer that translates your declarative spec into the rightzfsand release-script calls.
Where Next
-
Multi-host deploys —
converge_coordinated/1orchestrates the same DSL across N hosts. Health checks run as a phase before the deploy is declared successful; seespecs/HealthCheck.tlafor the protocol. -
Jails — wrap an app in a FreeBSD jail with
jail :name do ... end. Zed generates thejail.conf.dentry and ties the jail’s filesystem to the dataset. -
Cluster topology —
cluster :name do host ... enddeclares a set of hosts and their roles.Zed.Cluster.connect/1joins them over distributed Erlang. -
Live demo notebook —
notebooks/cluster-sampling-demo.livemdshows two jails (livebook + an MCMC compute backend) connected via:erpc, with state pulled across:erpc.call/4. No HTTP. No queue.
The full surface lives in lib/zed/dsl.ex. Reading the DSL module is
about 350 lines and worth your time — it’s the entirety of zed’s API.