The jails-versus-containers argument usually gets fought on aesthetics: which one feels cleaner, which one has better tooling, which community is more insufferable. None of that matters when you are the one holding the pager. What matters is blast radius, whether you can patch the thing on a Tuesday afternoon, whether you can prove a tenant is not eating the box, and what happens when you need to roll back. This is the operator's version of the comparison.
Jails Are a Partition; Containers Are an Assembly
A FreeBSD jail is a partition of the entire system namespace, enforced in the kernel, created by a single system call. When jail_set(2) succeeds, the process and every descendant it ever spawns are inside. Process visibility, filesystem root, network addressing, sysctl visibility, System V IPC, device access, and the ability to load kernel modules or mount filesystems are all constrained by one kernel-level decision. Jails have worked this way since FreeBSD 4.0 in 2000, and the model has been refined rather than replaced.
A Linux container is not a kernel object. It is an outcome you assemble: a set of namespaces (PID, mount, network, UTS, IPC, user, cgroup), plus a cgroup hierarchy for accounting, plus a seccomp filter, plus capability drops, plus usually an LSM profile, all orchestrated by a userland runtime. Each piece is well engineered, but the isolation is a property of the composition rather than of a kernel primitive -- which is why container escapes so often turn out to be "the runtime forgot to set one thing." Calling that bolted on is not a cheap shot; it describes the development history. Namespaces arrived incrementally over roughly a decade, cgroups came from a different problem domain, and the container abstraction was retrofitted on top. FreeBSD got to design the boundary first and the features second. That is an accident of history, not virtue, but it produced a system where the default is closed and you open specific doors.
Those doors are the allow.* parameters. You grant raw sockets if a jail needs ping. You grant allow.mount.zfs if it manages its own datasets. FreeBSD 15.1, released 2026-06-16, added allow.vmm_ppt, which gates whether a jailed bhyve instance may use PCI passthrough -- previously an all-or-nothing property of running bhyve in a jail at all. The same release added a UNIX-domain-socket framebuffer so a jailed VM can expose graphics to the host without opening a VNC port on the network. Firewall control follows the same pattern: net.inet.ipf.jail_allowed decides whether jails may manage their own ipfilter rules. The list of things a jail can do is enumerable, auditable, and lives in one config file.
What a Jail Definition Actually Looks Like
jail.conf(5) is a small declarative language with variables, inheritance, and per-jail overrides. Global settings sit at the top level; each named block inherits and overrides. Here is a realistic VNET jail on a bridged network:
# /etc/jail.conf
# Global defaults inherited by every jail below
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
exec.consolelog = "/var/log/jail_console_${name}.log";
exec.clean;
mount.devfs;
devfs_ruleset = 5;
path = "/usr/local/jails/containers/${name}";
host.hostname = "${name}.example.com";
persist;
web01 {
$id = "21";
$ip = "10.20.0.${id}/24";
$gw = "10.20.0.1";
$bridge = "bridge0";
$epair = "epair${id}";
vnet;
vnet.interface = "${epair}b";
allow.raw_sockets;
exec.prestart = "/sbin/ifconfig ${epair} create up";
exec.prestart += "/sbin/ifconfig ${epair}a up descr jail:${name}";
exec.prestart += "/sbin/ifconfig ${bridge} addm ${epair}a up";
exec.start += "/sbin/ifconfig ${epair}b ${ip} up";
exec.start += "/sbin/route add default ${gw}";
exec.poststop = "/sbin/ifconfig ${bridge} deletem ${epair}a";
exec.poststop += "/sbin/ifconfig ${epair}a destroy";
}
The lifecycle is equally boring, which is the point. There is no daemon to keep alive and no socket to protect:
jail -c web01 # create and start
jls -N # list running jails with parameters
jexec web01 /bin/sh # shell inside
jail -m web01 name=web01 host.hostname=new.example.com # modify live
jail -r web01 # stop and destroy
# Persist across reboots
sysrc jail_enable=YES
sysrc jail_list="web01 db01"
service jail start web01
Use /etc/jail.conf.d/<name>.conf for one file per jail if you are managing more than a handful; the include is automatic and it makes configuration management far less painful.
VNET, or a Shared Stack With an Alias
VNET gives a jail its own complete network stack: its own routing table, its own interfaces, its own firewall state, its own netstat output. You need it when the jail runs a VPN endpoint, needs its own default gateway, must run pf or ipfw rules independently, or belongs to a tenant who gets to break their own networking without touching yours.
You do not need it for most workloads. A shared-stack jail with ip4.addr = 10.20.0.21; and interface = igb0; gets an IP alias on the host interface, shares the host's routing table, and costs nothing. The failure mode I see most often is teams reaching for VNET everywhere, then spending an afternoon on epair interfaces that never got cleaned up after an unclean shutdown. Start shared; move to VNET when a jail actually needs to own its stack.
rctl and RACCT: The Accounting Everyone Assumes Is Missing
The most common objection to jails is that they lack cgroups-equivalent resource control. That has been wrong for over a decade. RACCT does the accounting, rctl(8) enforces the policy. Enable it at boot:
# /boot/loader.conf
kern.racct.enable=1
# Hard memory ceiling -- allocations beyond this fail
rctl -a jail:web01:memoryuse:deny=4G
# CPU: pcpu is percent of one core, so 200 = two cores' worth
rctl -a jail:web01:pcpu:deny=200
# Disk I/O throttling rather than denial
rctl -a jail:web01:readbps:throttle=50000000
rctl -a jail:web01:writebps:throttle=25000000
# Bound the process table
rctl -a jail:web01:maxproc:deny=512
# What is this jail actually consuming right now?
rctl -hu jail:web01
Persist rules in /etc/rctl.conf, one per line, same syntax minus the rctl -a. The actions matter: deny refuses the allocation, log records it, throttle slows the offender, and sigterm/devctl let you build reactions. throttle on I/O is the one people miss, and it is the difference between one noisy tenant degrading a pool and one noisy tenant merely getting slow. If you are pairing this with pool-level work, the dataset choices in ZFS tuning for production FreeBSD servers matter as much as the rctl rules.
ZFS Dataset Per Jail: The Real Advantage
This is where jails stop being merely comparable to containers and start being better. Give every jail its own dataset. Provisioning becomes a clone, which is instant and consumes no space until the jail diverges:
# One-time: build a template and snapshot it
zfs create -p zroot/jails/templates/15.1-RELEASE
# ... extract base.txz / install pkgbase into it ...
zfs snapshot zroot/jails/templates/15.1-RELEASE@base
# Provision a new jail in under a second
zfs clone zroot/jails/templates/15.1-RELEASE@base \
zroot/jails/containers/web02
# Per-jail quota and tuning, no rctl required for space
zfs set quota=20G zroot/jails/containers/web02
zfs set recordsize=16K zroot/jails/containers/db01
zfs set compression=zstd zroot/jails/containers/logs01
The payoff is rollback. A container image is immutable and its state lives somewhere else, so recovering a bad deploy means rebuilding and replaying state. A jail on its own dataset is one snapshot away from exactly the state it had before you touched it, application data included -- a thirty-second undo for the whole world the jail lives in. Replication to another host is zfs send | ssh zfs recv -- no registry, no image format, no layer cache.
Upgrades: Patch, Don't Rebuild
The container model says treat the OS as immutable and rebuild the image. That is a real discipline with real benefits, and a lot of machinery to maintain. The jail model patches the userland in place, which is fine because that userland is a directory tree you own. For a classic jail, freebsd-update -b /usr/local/jails/containers/web01 fetch install does it. With pkgbase, base becomes ordinary packages:
zfs snapshot zroot/jails/containers/web01@pre-15.1
pkg -j web01 update -r FreeBSD-base
pkg -j web01 upgrade -r FreeBSD-base
service jail restart web01
# If it goes badly
service jail stop web01
zfs rollback zroot/jails/containers/web01@pre-15.1
service jail start web01
The kernel is the host's, so a jail userland can trail the host within the supported ABI window and you can upgrade jails one at a time on your own schedule. The step-by-step version, including repository configuration and the gotchas, is in upgrading a FreeBSD jail from 15.0 to 15.1 with pkgbase.
Tooling, and an Opinion
Plain jail.conf is base-system and every FreeBSD admin can read it. Bastille adds templates, a Bastillefile format, and ZFS-aware clone provisioning while still writing normal jail configuration underneath. iocage is capable but slow-moving, and its Python dependency has bitten upgrades. CBSD is enormously featureful and correspondingly heavy.
My opinion: use plain jail.conf.d plus your existing configuration management for fewer than about ten jails, and Bastille when you want repeatable templates and want to stay close to the base system. Do not adopt a manager that hides jail.conf from you -- at 3 a.m. you want to read one file, not reverse-engineer a wrapper's state directory.
What You Give Up
Honesty first, because the tradeoffs are real. Linux binaries do not run natively; the linuxulator has improved substantially and handles a lot of Ubuntu userland, but anything reaching for a modern kernel interface will fail, and vendors will not support you there. The prebuilt-image ecosystem does not exist -- there is no Docker Hub for jails, and you build your templates. There is no Kubernetes equivalent, so multi-host scheduling, service discovery, and rolling deploys are yours to solve with the tools you already have. The hiring pool is a fraction of the Linux one, and that is a genuine business risk on a team you did not build. Some of the argument for the platform overall is covered in why FreeBSD over Linux for production servers, but none of it makes those four items go away.
When Jails, When Containers
Reach for jails when you run a modest number of long-lived services on hardware you control, when multi-tenancy and blast radius are the priority, when the workload is storage-heavy and ZFS integration pays, and when you would rather patch than rebuild. Hosting, mail, DNS, databases, network appliances, and per-customer isolation are the sweet spot.
Reach for Linux containers when your deployment target is a managed Kubernetes cluster, when you consume vendor images you do not build, when the workload is ephemeral and scheduled rather than long-lived, or when your team's operational muscle memory is entirely Linux. Choosing a technically better isolation primitive that nobody on the team can debug is not a win.
The takeaway: jails are not a lightweight approximation of containers. They are a different design that trades ecosystem breadth for a boundary the kernel enforces by default and for storage semantics no container runtime matches. If your workloads look like servers rather than like scheduled jobs, that trade is usually the right one -- and once they are running, instrument them properly with the approach in the FreeBSD server monitoring guide.