$RodHat_
Console Tips

ZFS boot environments + jails will do 90% of what you're using Docker for, and the other 10% is the part you don't actually need

Published by

A computer screen with a program running on it
Photo: Mohammad Rahmani / Unsplash

I was wrong about containers for about six years. I said, loudly, to anyone in earshot, that jails already solved this and Docker was reinventing chroot with a marketing budget. That was half right and half stubborn — the OCI image format and the ecosystem around it (registries, orchestration, the whole docker builddocker pushk8s pull pipeline) solved a real distribution problem that jails never addressed. I’ll give ground on that.

What I won’t give ground on: for a huge fraction of workloads — a single service, on one box or a handful of boxes, where you’re not shipping images to a fleet of strangers — jails on ZFS give you tighter isolation with less machinery, and most people running Docker for that use case are paying a complexity tax for a distribution problem they don’t actually have.

The setup

Every jail gets its own ZFS dataset. That’s the whole trick, and it’s why this pairs so well with ZFS specifically instead of UFS:

zfs create zroot/jails
zfs create zroot/jails/myapp

Base the jail off a clean userland snapshot instead of re-extracting a tarball every time:

zfs snapshot zroot/jails/base@clean
zfs clone zroot/jails/base@clean zroot/jails/myapp

zfs clone gives you a copy-on-write jail root that costs you essentially zero disk until it diverges from the base. That’s your “image layer” story, and it’s arguably cleaner than OCI layers because it’s the actual filesystem doing the work, not a union-mount abstraction on top of it.

The jail config

/etc/jail.conf:

myapp {
  host.hostname = "myapp.internal";
  path = "/jails/myapp";
  ip4.addr = "10.0.0.12";
  exec.start = "/bin/sh /etc/rc";
  exec.stop = "/bin/sh /etc/rc.shutdown";
  mount.devfs;
  allow.raw_sockets = 0;
  enforce_statfs = 2;
}

enforce_statfs = 2 is the line most people skip and shouldn’t — it hides the host’s actual filesystem structure from the jail entirely, so df inside the jail shows only what’s mounted for it. That’s a real isolation boundary at the kernel level, not a namespace convention userland processes are trusting each other to respect.

The part that actually beats containers: boot environments as instant rollback

This is the trick that made me stop being smug about jails in the abstract and start actually recommending the workflow. Before you push a change into a jail, snapshot it:

zfs snapshot zroot/jails/myapp@pre-deploy-2026-07-23

Deploy breaks something? You’re not debugging a broken container image or rebuilding from a Dockerfile you hope is still reproducible:

zfs rollback zroot/jails/myapp@pre-deploy-2026-07-23
service jail restart myapp

That’s an atomic, filesystem-level rollback of the entire jail root, in the time it takes ZFS to flip a pointer. No image rebuild, no registry round-trip, no “well it worked in the previous image tag, let me go find it.”

Where I’ll actually concede the point

If you need to ship the same artifact to a hundred heterogeneous machines you don’t control, or you need the orchestration ecosystem — service mesh, autoscaling, the whole Kubernetes universe — jails alone don’t give you that story, and pretending otherwise is exactly the stubbornness I copped to above. Use the right tool. I just want the “jails are obsolete” crowd to sit with the fact that for a single-tenant service on hardware you own, this workflow is faster, lighter, and gives you a rollback primitive most container setups have to bolt on separately.

If you’re the one who has to own this decision instead of just having opinions about it in a Slack thread, The Practice of System and Network Administration is still the best book I know for reasoning about operational tradeoffs like this one without reaching for whatever’s trendy.