Getting started¶
Quick start on NixOS¶
Add the flake to the host:
{
inputs.zfs-tenant.url = "github:basnijholt/zfs-tenant";
outputs = { nixpkgs, zfs-tenant, ... }: {
nixosConfigurations.nas = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
zfs-tenant.nixosModules.host
./configuration.nix
];
};
};
}
On the host, give Joe a tenant root:
services.zfs-tenant = {
enable = true;
tenants.joe = {
dataset = "tank/friends/joe";
quota = "2T";
authorizedKeys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... joe-nas" ];
allowedFrom = [ "100.64.0.12" ]; # Joe's tailnet address
};
};
This creates the user zfs-tenant-joe, pins the key to the gate, applies the dataset, properties, and delegation on every boot, and runs zfs-tenant-zone-joe.service, which holds Joe's zone.
Each tenant needs a distinct, dedicated account with no other SSH keys, services, sudo rights, or password login. The module requires SSH PAM sessions and starts each forced command in a user scope under zfs-tenant-joe.slice. By default, that slice has MemoryMax=512M, TasksMax=64, and CPUQuota=100%; PAM sets a hard and soft nproc limit of 128 for the account. Adjust these with tenants.joe.resourceLimits.memoryMax, tasksMax, cpuQuota, and processLimit if needed. Keep the installed package and its path parents, receiver key files, and namespace pid file controlled by root.
The host needs zfs zone support and a patched loaded OpenZFS kernel module. The OpenZFS security advisory for CVE-2026-79619 lists fixed upstream releases 2.4.4, 2.3.9, and 2.2.11; for vendor backports, confirm the fix with the vendor. Check the loaded version with cat /sys/module/zfs/version; an updated zfs tool alone does not update the loaded kernel module.
Setup audits the root and its descendants before changing an existing tree and rejects unexpected delegation. If it fails, inspect the dataset named in the error with zfs allow (for example, zfs allow tank/friends/joe), remove unsafe grants explicitly as the administrator, and rerun setup. It does not silently revoke unrelated grants.
Set reservation = "2T"; as well if you want to guarantee Joe the space and hide how full your pool is.
Joe needs nothing from zfs-tenant: he pushes with nixpkgs' own services.syncoid.
The VM test runs this configuration, with only its host name, pool, and key changed:
programs.ssh.knownHosts.bas-nas.publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...";
services.syncoid = {
enable = true;
sshKey = "/var/lib/syncoid/id_ed25519";
# The default also grants snapshot, destroy, bookmark, and mount.
localSourceAllow = [
"send"
"hold"
];
commonArgs = [
"--no-sync-snap"
"--compress=none"
"--delete-target-snapshots"
"--sshoption=StrictHostKeyChecking=yes"
];
commands."tank/offsite" = {
target = "zfs-tenant-joe@bas-nas:tank/friends/joe/offsite";
recursive = true;
sendOptions = "w";
};
};
services.syncoid runs syncoid hourly as the unprivileged syncoid user in a sandbox, always passes --no-privilege-elevation, and grants localSourceAllow only for the duration of each run.
Pushing with syncoid by hand explains the other flags.
Create the key once, readable only by the syncoid user, and send Joe's public half to the host:
sudo install -d -m 700 -o syncoid -g syncoid /var/lib/syncoid
sudo -u syncoid ssh-keygen -t ed25519 -N '' -f /var/lib/syncoid/id_ed25519
The source dataset (tank/offsite here) must be encrypted, and sanoid should snapshot it: with --no-sync-snap, syncoid only sends the snapshots sanoid made.
A failed push shows up in systemctl status syncoid-tank-offsite.
To get alerted, monitor the age of the newest snapshot that reached the host for each dataset you push (zfs list -r -t snapshot -o name,creation -s creation through the gate), so one healthy dataset cannot hide another that stopped replicating; this also catches a timer that never runs.
On the tailnet, allow only Joe's node to reach port 22 on your host.
Manual setup (TrueNAS SCALE or any Linux)¶
The gate uses only the Python standard library, so a single file is enough.
Download zfs-tenant.pyz from the latest release and keep it on a pool dataset, so it survives appliance updates:
curl -L -o /mnt/tank/admin/zfs-tenant.pyz \
https://github.com/basnijholt/zfs-tenant/releases/latest/download/zfs-tenant.pyz
Or install it with uv tool install zfs-tenant or pip install zfs-tenant where that is possible.
- Create a dedicated local user for Joe with a normal login shell such as bash, no password login, no extra groups or sudo rights, no other SSH keys, and no other services running as that user. sshd runs forced commands through the login shell. On TrueNAS, give the user a home directory on a pool dataset so its
authorized_keyspersists. Keep the zipapp, every parent directory in its path, the receiver'sauthorized_keysand its parent directories, and the namespace pid file and its parent directory root-owned and unwritable by Joe. - Preview the initial setup commands, then run setup as root:
python3 -I zfs-tenant.pyz setup --root tank/friends/joe --user joe --quota 2T --dry-run
sudo python3 -I zfs-tenant.pyz setup --root tank/friends/joe --user joe --quota 2T
Delegation and properties live in the pool, so they survive reboots and appliance updates. Setup rejects any unexpected grants on an existing tenant root or its descendants and checks a new root for grants copied from its parent. If it rejects a tree, inspect the dataset named in the error with zfs allow (for example, zfs allow tank/friends/joe), remove unsafe grants yourself, and rerun setup; it will not silently revoke them.
On subsequent runs, setup checks the root's mountpoint and skips resetting it when it is already locally set to none; OpenZFS rejects even an unchanged mountpoint write once zoned children inherit it.
3. Produce the authorized_keys line and put it in that user's ~/.ssh/authorized_keys:
python3 -I zfs-tenant.pyz authorized-key \
--gate-command "/usr/bin/python3 -I /mnt/tank/admin/zfs-tenant.pyz gate --root tank/friends/joe --zfs /usr/sbin/zfs --zpool /usr/sbin/zpool --zone-pid-file /run/zfs-tenant-joe.pid" \
--from 100.64.0.12 \
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... joe-nas
- Start Joe's zone at boot, as root. On TrueNAS, add this as a post-init script:
nohup python3 -I /mnt/tank/admin/zfs-tenant.pyz zone --root tank/friends/joe --user joe \
--pid-file /run/zfs-tenant-joe.pid --zfs /usr/sbin/zfs >/var/log/zfs-tenant-joe.log 2>&1 &
setup sets zoned=on, and the gate command above refuses to run until this holder is up. The kernel must allow unprivileged user namespaces (Debian and NixOS do by default). Manual installation does not install the NixOS slice or PAM resource controls: arrange equivalent receiver limits yourself. Verify that the loaded OpenZFS module includes the fix described in the upstream advisory, or a vendor-confirmed backport.