What FreeBSD Jails Actually Are (and Are Not)
A jail is an OS-level container that restricts a process tree to a subtree of the filesystem and limits its ability to affect the wider system. Unlike Linux namespaces, which are composable primitives, a FreeBSD jail is a single syscall - `jail(2)` - that applies all restrictions atomically. The kernel enforces the boundary: a jailed process cannot call `chroot()` to escape, cannot load kernel modules, and cannot see processes outside its jail unless the host explicitly permits it via `allow.sysvipc` or similar flags.
Jails are not VMs. There is one kernel. Every jail on the host shares the same FreeBSD kernel, which means you cannot run a Linux jail natively (though bhyve handles that case). What you gain is near-metal performance and extremely low overhead. On our test server, we ran 40 concurrent jails on a system with 8 GB RAM and the combined jail overhead was under 300 MB.
The three jail models you will encounter are: shared IP jails (the original model, one or more IPs aliased from the host), VNET jails (each jail gets its own network stack, introduced in FreeBSD 8), and nullfs jails (thin jails that share a base filesystem via `mount_nullfs`). We will cover all three.
# Check your FreeBSD version before starting
freebsd-version
uname -r
Building a Base Jail Filesystem
The first decision is where to store jail roots. ZFS datasets are the right answer in 2026 because you get per-jail snapshots, cloning for rapid provisioning, and quotas at no extra cost. We use `/jails` as the mount point throughout.
Create the ZFS pool structure first, then fetch the base system. The `bsdinstall jail` subcommand does this cleanly, but fetching tarballs directly gives you more control.
On a typical setup you want at minimum `base.txz` and for jails that need DNS resolution you also want to seed `/etc/resolv.conf` manually - it is not inherited automatically.
# Create ZFS datasets for jails
zfs create -o mountpoint=/jails zroot/jails
zfs create zroot/jails/base
# Fetch FreeBSD 14.1 base for the jail (match your host version)
fetch -o /tmp/base.txz \
https://download.freebsd.org/releases/amd64/14.1-RELEASE/base.txz
# Extract into the base dataset
tar -xf /tmp/base.txz -C /jails/base
# Update the jail base immediately
freebsd-update -b /jails/base fetch install
# Minimal /etc setup inside the jail
cp /etc/resolv.conf /jails/base/etc/resolv.conf
echo 'nameserver 1.1.1.1' > /jails/base/etc/resolv.conf
The /etc/jail.conf Format
FreeBSD 14 uses `/etc/jail.conf` as the primary configuration file. The syntax is its own DSL - not shell, not ini. Variables defined at the top level apply to all jails unless overridden per-jail block. This is the mechanism that eliminates repetition when you are managing a dozen jails.
The `exec.start` and `exec.stop` hooks run as root inside the jail by default. Use `exec.jail_user` to drop privileges. The `path` directive sets the jail root - make sure it is an absolute path.
One subtlety: if you set `host.hostname` to something like `webserver01.internal`, make sure the name resolves inside the jail if any service depends on it. We have seen PostgreSQL refuse to start in a jail because `localhost` resolved differently than the jail hostname. The fix is to add the hostname explicitly to the jail's `/etc/hosts`.
# /etc/jail.conf - production-style example
# Global defaults applied to all jails
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
exec.clean;
mount.devfs;
allow.raw_sockets;
# Shared IP jail example
web01 {
host.hostname = "web01.internal";
path = "/jails/web01";
ip4.addr = "lo1|10.0.0.1/24";
interface = "lo1";
allow.raw_sockets = 0;
}
# A jail that needs its own loopback
db01 {
host.hostname = "db01.internal";
path = "/jails/db01";
ip4.addr = "lo1|10.0.0.2/24";
exec.poststart = "echo 'db01 started' >> /var/log/jails.log";
}
Starting and Inspecting Jails
The `service jail start` command reads `/etc/jail.conf` and starts all jails where `jail_enable` is set. For finer control during initial testing, use `jail -c` directly with flags on the command line - it lets you iterate without editing the config file each time.
Once running, `jls` is your primary inspection tool. The `-v` flag gives you the full attribute list per jail, which is useful for scripting. `jexec` drops you into a running jail as root by default, or use `-u username` to enter as a specific user.
When a jail fails to start, the error message usually appears in `/var/log/messages` and on stdout if you run `jail -vc jailname` interactively. The `-v` flag on `jail(8)` increases verbosity significantly.
# Enable jail service at boot
sysrc jail_enable="YES"
sysrc jail_list="web01 db01"
# Start a specific jail
service jail start web01
# Or start directly for debugging
jail -vc web01
# List running jails
jls
jls -v
# Execute a command inside a jail (by name)
jexec web01 /bin/sh
jexec web01 pkg install -y nginx
# Check jail resource usage
top -a -J web01
# Stop a jail cleanly
service jail stop web01
VNET Jails: Per-Jail Network Stacks
VNET gives each jail its own network stack - its own routing table, its own interface state, its own firewall rules. This is the configuration you want for anything that needs to bind to port 80 on the same IP as another jail, or for jails that need internal routing.
VNET requires `vnet` in `jail.conf` and a virtual ethernet pair (`epair`) created on the host. The `epair` driver creates a pair of interfaces - `epair0a` and `epair0b` - where one end stays on the host and the other moves into the jail. You bridge the host end to your physical interface.
The setup requires the `if_epair` kernel module. Load it once and add it to `/boot/loader.conf`. We also use `bridge` for linking epair interfaces to the physical network.
For jails that need outbound internet access, configure NAT on the host using `pf` or `ipfw`. On our test server we use `pf` with a simple NAT rule - the jail traffic exits through `em0` with the host's public IP.
# Load required modules (add to /boot/loader.conf for persistence)
kldload if_epair
kldload if_bridge
# Add to /boot/loader.conf
echo 'if_epair_load="YES"' >> /boot/loader.conf
echo 'if_bridge_load="YES"' >> /boot/loader.conf
# Create bridge on host (do this in /etc/rc.conf for persistence)
ifconfig bridge0 create
ifconfig bridge0 addm em0 up
# /etc/jail.conf VNET jail entry
vnet01 {
host.hostname = "vnet01.internal";
path = "/jails/vnet01";
vnet;
vnet.interface = "epair0b";
exec.prestart = "ifconfig epair0 create up";
exec.prestart += "ifconfig bridge0 addm epair0a up";
exec.start = "/bin/sh /etc/rc";
exec.poststart = "jexec vnet01 ifconfig epair0b 10.0.1.10/24 up";
exec.poststart += "jexec vnet01 route add default 10.0.1.1";
exec.prestop = "jexec vnet01 ifconfig epair0b down";
exec.poststop = "ifconfig bridge0 deletem epair0a";
exec.poststop += "ifconfig epair0a destroy";
}
# /etc/pf.conf NAT rule for VNET jails
# nat on em0 from 10.0.1.0/24 to any -> (em0)
Thin Jails with nullfs: Share a Base, Isolate Configuration
Thin jails mount a read-only base filesystem via `nullfs` and layer a per-jail read-write directory on top. This saves disk space significantly - 40 thin jails sharing one 800 MB base use less space than 3 full jails. The tradeoff is that all thin jails share the same base binaries, so a base update propagates immediately.
The directory layout uses a `skeleton` directory per jail that contains only what differs from the base: `/etc`, `/var`, `/tmp`, `/root`, and `/usr/local`. These are bind-mounted (via nullfs) over the corresponding directories in the base.
This is the model that `ezjail` implements. If you are managing more than five jails, use ezjail rather than hand-rolling the nullfs mounts yourself.
When naming internal services inside thin jails, keep hostnames and service names distinct and machine-readable. If you are spinning up many jails for a project and also registering public domain names for them, a service like nicename.me helps find clean, available names without wading through expired domain databases.
# Thin jail filesystem layout
mkdir -p /jails/skeleton/{etc,var,tmp,root,usr/local}
# Per-jail directory gets only the skeleton
mkdir -p /jails/thin01
mkdir -p /jails/skeleton/thin01
# /etc/fstab.thin01 - nullfs mounts
# /jails/base /jails/thin01/ nullfs ro 0 0
# /jails/skeleton/thin01/etc /jails/thin01/etc nullfs rw 0 0
# /jails/skeleton/thin01/var /jails/thin01/var nullfs rw 0 0
# /jails/skeleton/thin01/tmp /jails/thin01/tmp nullfs rw 0 0
# /etc/jail.conf thin jail entry
thin01 {
host.hostname = "thin01.internal";
path = "/jails/thin01";
ip4.addr = "lo1|10.0.0.10/24";
mount.fstab = "/etc/fstab.thin01";
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
}
Managing Jails with ezjail
ezjail has been the standard jail manager on FreeBSD for over a decade. It handles base creation, flavor templating, and jail lifecycle management with sensible defaults. Install it from ports or pkg, then run `ezjail-admin install` to create the base jail from your running system.
Flavors are ezjail's equivalent of cloud-init. A flavor is a directory of files and scripts that get copied into a new jail at creation time. A minimal flavor might include a pre-configured `resolv.conf`, a default `sshd_config`, and a `pkg.conf` pointing at your local mirror. Store flavors in `/usr/jails/flavours/`.
On our test server we maintain a `base` flavor that installs a standard set of packages (`sudo`, `bash`, `curl`, `ca_root_nss`) via an `ezjail.flavour` script. Every new jail gets this flavor and is ready for use within 30 seconds of creation.
For teams looking to automate jail provisioning as part of a broader DevOps pipeline, platforms like taskbotshub.ai can trigger ezjail commands via webhook, making jail creation part of a CI/CD workflow without writing a custom daemon.
# Install ezjail
pkg install -y ezjail
# Enable and configure
sysrc ezjail_enable="YES"
# Create the basejail from local install (mirrors your host version)
ezjail-admin install -p
# Or fetch from FreeBSD mirrors
ezjail-admin install -r 14.1-RELEASE
# Create a new jail
ezjail-admin create web02 'lo1|10.0.0.20'
# Create with a flavor
ezjail-admin create web03 'lo1|10.0.0.21' -f base
# Start a jail
ezjail-admin start web02
# List all jails
ezjail-admin list
# Archive (backup) a jail to tarball
ezjail-admin archive web02
# Delete a jail
ezjail-admin delete -w web02
# Update the basejail
ezjail-admin update -b
ezjail-admin update -p # update ports in basejail
Resource Limits and Security Hardening
By default, a jail can consume as much CPU and RAM as available. Use `rctl(8)` to enforce limits. First verify RCTL is compiled in - check `sysctl kern.racct.enable`. If it returns 0, add `kern.racct.enable=1` to `/boot/loader.conf` and reboot.
Set limits per-jail using the jail name as the subject. Limits survive jail restarts if you add them to `exec.poststart` in `jail.conf`. The common limits are `memoryuse` (RSS cap), `pcpu` (percentage of one CPU core), `openfiles`, and `maxproc`.
For security, the `allow.*` jail parameters in `jail.conf` are your first line of defense. Disable what you do not need. The defaults in FreeBSD 14 are conservative, but explicitly setting them documents your intent and prevents accidental inheritance from future config changes.
Mount `/dev/fd`, `/dev/null`, `/dev/random`, and `/dev/urandom` inside the jail's `/dev` - devfs handles this automatically via `mount.devfs`, but you can restrict which device nodes appear using `devfs ruleset`. We use ruleset 4 (the default jail ruleset) which blocks `/dev/bpf*`, `/dev/mem`, and similar dangerous nodes.
# Enable RCTL (add to /boot/loader.conf, then reboot)
echo 'kern.racct.enable=1' >> /boot/loader.conf
# Verify after reboot
sysctl kern.racct.enable
# Set resource limits on a running jail
rctl -a jail:web01:memoryuse:deny=512m
rctl -a jail:web01:pcpu:deny=50
rctl -a jail:web01:maxproc:deny=200
rctl -a jail:web01:openfiles:deny=1024
# View current limits for a jail
rctl -u jail:web01
# Persist limits via exec.poststart in jail.conf
# exec.poststart += "rctl -a jail:web01:memoryuse:deny=512m";
# Security parameters to set in jail.conf
# allow.raw_sockets = 0;
# allow.sysvipc = 0;
# allow.mount = 0;
# allow.set_hostname = 0;
# enforce_statfs = 2;
# children.max = 0; # disallow nested jails unless needed
# Apply devfs ruleset 4 (standard jail ruleset)
# devfs_ruleset = 4; # in jail.conf
Package Management Inside Jails
Each jail gets its own `pkg` database at `/var/db/pkg` inside the jail root. Run `pkg -j jailname install package` from the host to install into a jail without entering it. This is useful for scripting installs across many jails.
For environments with many jails, configure a local `pkg` mirror or use `pkg` with a shared cache directory. Set `PKG_CACHEDIR` to a nullfs-mounted directory on the host to avoid downloading the same packages repeatedly. We cut our provisioning time from 4 minutes to 40 seconds by sharing a 2 GB pkg cache across all jails on the host.
If you run services that need frequent updates (Nginx, PostgreSQL, Redis), pin them with `pkg lock` to prevent accidental upgrades during `pkg upgrade -y` runs. Always test in a ZFS snapshot clone before upgrading production jails.
# Install package into a jail from the host
pkg -j web01 install -y nginx
pkg -j web01 install -y postgresql15-server
# Bootstrap pkg inside a new jail
jexec web01 env ASSUME_ALWAYS_YES=yes pkg bootstrap
# Shared pkg cache via nullfs (add to /etc/fstab or jail fstab)
# /jails/pkg-cache /jails/web01/var/cache/pkg nullfs rw 0 0
# Lock a package version
pkg -j web01 lock nginx
# Upgrade all packages in a jail
pkg -j web01 upgrade -y
# List installed packages
pkg -j web01 info
# ZFS snapshot before upgrade
zfs snapshot zroot/jails/web01@before-upgrade
# Roll back if needed
zfs rollback zroot/jails/web01@before-upgrade
Logging, Monitoring, and Troubleshooting
Jails do not get their own syslogd by default - log messages go to the host's `/var/log/messages`. For production use you have two options: run a syslogd inside each jail, or configure the host syslogd to write per-jail log files using the `jid` tag.
On FreeBSD 14, `syslogd` supports `-a` for specifying additional sockets. Start syslogd inside a jail with `syslogd_flags="-ss"` in the jail's `/etc/rc.conf` - the double `-s` prevents it from listening on the network socket, which is appropriate for an isolated jail.
For performance monitoring, `top -a -J jailname` filters by jail. `procstat -a` inside a jail shows only jail-visible processes. For host-side visibility across all jails, `ps -ax -J jailname` works from the host.
Common failure modes and their fixes: a jail that hangs on stop is usually waiting for a process that caught SIGTERM but ignores it - check `jexec jailname ps aux` and kill the offender manually. A jail that fails to mount devfs usually has a devfs ruleset mismatch - check `devfs rule showsets` on the host. Network connectivity failures in VNET jails are almost always a missing `route add default` inside the jail or a missing NAT rule on the host.
# Per-jail syslog in jail's /etc/rc.conf
# syslogd_enable="YES"
# syslogd_flags="-ss"
# Host syslogd: add socket for each jail (syslogd_flags in host /etc/rc.conf)
# syslogd_flags="-a /jails/web01/var/run/log"
# Monitor processes across all jails from host
ps -axJ
ps -ax -J web01
# Check jail network state (for VNET jails)
jexec vnet01 netstat -rn
jexec vnet01 ifconfig
# Debug a jail start failure verbosely
jail -vc web01 2>&1 | tail -50
# Find which jail owns a PID
procstat -a | awk '{print $1, $2}' | grep
jls -p
# Force stop a stuck jail
jail -r web01
# If that fails, kill all processes in the jail
kill $(jls -j web01 -q jid | xargs -I{} jexec {} ps -ax -o pid= | tr '\n' ' ')