FreeBSD Jails: A Comprehensive Guide¶
Table of Contents¶
- What Jails Are and Why They Exist
- Core Concepts
- Preparing the Host
- Creating a Jail Manually
- Managing Jails with
jail(8)andjls(8) - Declarative Jails with
/etc/jail.conf - Networking Models
- VNET Jails (Virtualized Networking)
- ZFS Integration
- Using
ezjail - Using
iocage - Using
bastille - Jails vs. Docker/Linux Containers
- Resource Limits (
rctl/racct) - Persistent vs. Non-Persistent Jails
- Updating and Patching Jails
- Security Hardening
- Thick vs. Thin Jails
- Troubleshooting
- Quick Reference Cheat Sheet
1. What Jails Are and Why They Exist¶
FreeBSD jails, introduced in FreeBSD 4.0 (2000) by Poul-Henning Kamp, were one of the earliest production-grade OS-level virtualization mechanisms — predating Linux containers by nearly a decade. A jail partitions the operating system environment so that processes inside it see a restricted view of the system: their own filesystem root, their own process list, their own network stack (optionally), and their own set of privileges, while all jails still share the same kernel as the host.
The original motivation was shared-hosting security: give each customer a chroot-like environment that also restricted networking, process visibility, and superuser capabilities, so that a compromise inside one jail could not reach the host or other jails. Over 25 years, jails have grown well beyond that use case into a general-purpose lightweight virtualization primitive used for:
- Isolating services (web servers, databases, DNS) from each other and from the host
- Running multiple versions of FreeBSD userland side-by-side on one kernel
- Reproducible build/test environments (poudriere uses jails to build packages)
- Lightweight "VMs" for homelab and hosting use, especially combined with ZFS
Because jails share the host kernel, they are far cheaper than full virtualization (bhyve, VMware, KVM): no emulated hardware, near-native performance, and very fast startup (typically well under a second).
2. Core Concepts¶
Jail root. Every jail has a root directory (its own /) that the host designates. Processes inside the jail cannot see or traverse above that directory — jails use a hardened form of chroot with additional kernel-level restrictions (a plain chroot can be escaped by a privileged process; a jail cannot, because the jail attribute is enforced by the kernel, not just the VFS layer).
Hostname isolation. Each jail has its own hostname, independent of the host's.
Process isolation. Processes inside a jail cannot see, signal, or interact with processes outside the jail (including the host and sibling jails), even if run as root.
Restricted privilege. Root inside a jail is not equivalent to root on the host. A long list of privileged operations are disabled by default inside jails: loading kernel modules, changing the securelevel, mounting/unmounting most filesystems, direct raw device access, adjusting the system clock, PF/IPFW firewall rule changes (unless explicitly enabled), and more. This is governed by the security.jail.* sysctls and the jail's parameter set.
Network isolation. Historically jails shared the host's network stack but were bound to a specific set of IP addresses (ip4.addr/ip6.addr) and could not bind to addresses outside that set, nor see host traffic on other addresses. Modern FreeBSD also supports VNET jails, which give a jail its own independent network stack (own routing table, own interfaces, own firewall state) — much closer to a true virtual machine's networking.
Resource control. Jails can be bound to CPU, memory, and other resource limits via rctl(8), and jail-specific devfs rulesets restrict which device nodes are visible inside the jail.
Nesting. Jails can be nested (a jail can itself contain jails) if the parent jail is given the children.max parameter and allow.* permissions, though this is uncommon outside specialized use cases.
3. Preparing the Host¶
3.1 Enable jail support¶
Jail support has been compiled into the GENERIC kernel by default since FreeBSD 8, so no kernel rebuild is needed on a stock install. Confirm:
sysctl security.jail.jailed
# security.jail.jailed: 0 (0 = not currently inside a jail, which is what you want on the host)
3.2 Enable the jail service¶
sysrc jail_enable="YES"
3.3 Decide on a filesystem layout¶
A common convention:
/jails/
├── media/ # distribution sets used as templates (thin jails)
├── templates/
└── containers/
├── webserver/
├── dns/
└── database/
If you're using ZFS (strongly recommended — see Section 9):
zfs create -o mountpoint=/jails zroot/jails
zfs create zroot/jails/containers
zfs create zroot/jails/media
3.4 Fetch a base userland¶
Each jail (unless it's a "thin" jail sharing a base via nullfs) needs a copy of the FreeBSD base system matching the host's ABI/architecture — it does not need to match the host's exact patch level, only the major release compatibility.
mkdir -p /jails/media/13.3-RELEASE
fetch https://download.freebsd.org/ftp/releases/amd64/amd64/13.3-RELEASE/base.txz \
-o /tmp/base.txz
tar -xf /tmp/base.txz -C /jails/media/13.3-RELEASE
4. Creating a Jail Manually¶
This is the "by hand" method — useful for understanding what higher-level tools like ezjail/iocage/bastille automate for you.
4.1 Populate the jail root¶
mkdir -p /jails/containers/webserver
tar -xf /tmp/base.txz -C /jails/containers/webserver
4.2 Configure basic files inside the jail¶
cp /etc/resolv.conf /jails/containers/webserver/etc/resolv.conf
Set the jail's timezone, and edit /jails/containers/webserver/etc/rc.conf to configure the in-jail hostname and services:
sysrc -f /jails/containers/webserver/etc/rc.conf hostname="webserver.example.internal"
sysrc -f /jails/containers/webserver/etc/rc.conf sendmail_enable="NONE"
4.3 Start it directly with jail(8) (one-shot, imperative)¶
jail -c \
path=/jails/containers/webserver \
host.hostname=webserver.example.internal \
ip4.addr=192.168.1.50 \
interface=em0 \
command=/bin/sh
This form runs a single command and exits — useful for testing, not for a long-lived service jail. For a persistent jail you almost always want a config-file-driven approach instead (Section 6), because it survives reboots and integrates with service jail start.
5. Managing Jails with jail(8) and jls(8)¶
jls— list running jails (JID, IP, hostname, path)jls -v— verbose listingjexec <jid-or-name> <command>— run a command inside a running jail, e.g.jexec webserver /bin/shto get an interactive shelljail -r <jid-or-name>— remove/stop a jailjail -m <jid-or-name> <param>=<value>— modify a running jail's parameters
Example: get a shell in a running jail named webserver:
jexec webserver /bin/csh
6. Declarative Jails with /etc/jail.conf¶
/etc/jail.conf is the modern, recommended way to define jails so they persist across reboots and are managed through the standard service jail interface. It supports global defaults plus per-jail blocks, and variable interpolation via $name.
6.1 Global defaults block¶
# /etc/jail.conf
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
exec.consolelog = "/var/log/jail_${name}_console.log";
mount.devfs;
allow.raw_sockets = false;
persist;
6.2 Per-jail block¶
webserver {
host.hostname = "webserver.example.internal";
path = "/jails/containers/webserver";
ip4.addr = "192.168.1.50";
interface = "em0";
devfs_ruleset = 4;
allow.raw_sockets = false;
mount.fstab = "/etc/fstab.webserver";
}
database {
host.hostname = "database.example.internal";
path = "/jails/containers/database";
ip4.addr = "192.168.1.51";
interface = "em0";
devfs_ruleset = 4;
}
6.3 Enable and control jails¶
sysrc jail_enable="YES"
sysrc jail_list="webserver database"
service jail start webserver
service jail stop database
service jail restart webserver
Because jail.conf entries run /etc/rc and /etc/rc.shutdown inside the jail by default (matching the global block above), each jail behaves like a mini FreeBSD instance: enable services via the jail's own rc.conf (e.g., nginx_enable="YES" inside /jails/containers/webserver/etc/rc.conf), and they start/stop automatically with the jail.
6.4 Useful per-jail parameters¶
| Parameter | Purpose |
|---|---|
path |
Root directory of the jail |
host.hostname |
Hostname visible inside the jail |
ip4.addr / ip6.addr |
IP address(es) bound to the jail |
interface |
Network interface the address is added to |
devfs_ruleset |
Which devfs ruleset restricts visible device nodes |
allow.raw_sockets |
Permit ping/traceroute-style raw sockets inside the jail |
allow.sysvipc |
Permit System V IPC (needed by some databases) |
allow.mount / allow.mount.* |
Permit specific mount types (nullfs, procfs, etc.) |
mount.fstab |
Extra filesystems to mount into the jail at start |
exec.start / exec.stop |
Commands run on jail start/stop |
persist |
Keep the jail alive even with no running processes |
vnet |
Give the jail its own virtualized network stack |
children.max |
Allow nested jails |
7. Networking Models¶
FreeBSD jails support two fundamentally different networking approaches.
7.1 Shared-stack (classic / "IP alias") jails¶
The jail uses the host's network stack, but is restricted to specific IP address(es) via ip4.addr/ip6.addr. The address is typically an alias on a host interface. This is simple and low-overhead, but:
- The jail cannot manage its own firewall rules or routing table
- All jails share one ARP table, one set of interfaces
- The jail cannot bind to
0.0.0.0— only to its assigned address(es)
This model is fine for most single-purpose service jails (a jail running one web server, one DNS resolver, etc.).
7.2 VNET (virtualized network stack) jails¶
Covered in depth in Section 8 — each jail gets a genuinely separate network stack, much like a lightweight VM.
8. VNET Jails (Virtualized Networking)¶
VNET gives a jail its own routing table, its own interface list, and its own firewall state, fully independent from the host and other jails. This is required if you want a jail to run its own PF/IPFW ruleset, act as a router, or bind to 0.0.0.0.
8.1 Requirements¶
- A kernel with
VIMAGEsupport (the GENERIC kernel has included this since FreeBSD 12; verify withsysctl kern.conftxt | grep VIMAGEor checksysctl -a | grep vnet) if_bridgeandif_epairkernel modules (usually loadable, sometimes needkldload if_bridge if_epair)
8.2 Set up a bridge on the host¶
kldload if_bridge if_epair
ifconfig bridge0 create
ifconfig bridge0 addm em0 up
8.3 Create an epair (virtual cable) and attach one end to the bridge¶
jail.conf can automate epair creation with exec.prestart/exec.poststop hooks, but conceptually:
ifconfig epair0 create
ifconfig bridge0 addm epair0a
ifconfig epair0a up
The epair0b end is handed to the jail as its interior interface.
8.4 jail.conf entry for a VNET jail¶
router1 {
path = "/jails/containers/router1";
host.hostname = "router1.example.internal";
vnet;
vnet.interface = "epair0b";
exec.prestart = "ifconfig epair0 create";
exec.prestart += "ifconfig bridge0 addm epair0a up";
exec.start = "/bin/sh /etc/rc";
exec.poststop = "ifconfig bridge0 deletem epair0a";
exec.poststop += "ifconfig epair0a destroy";
}
Inside the jail, epair0b shows up as a normal interface that you configure with a standard ifconfig_epair0b="inet 10.0.0.2/24" in the jail's own /etc/rc.conf, exactly as you would on a full FreeBSD install. The jail can run its own PF instance, its own routing daemon, etc., completely independent of the host.
VNET jails cost slightly more overhead than shared-stack jails (a full network stack per jail) but are the right choice when a jail needs genuine network autonomy — e.g., a jail acting as a VPN gateway, or one you want to firewall independently of the host's rules.
9. ZFS Integration¶
ZFS and jails are a natural pairing on FreeBSD, and most higher-level jail managers (iocage, bastille) assume ZFS. Benefits:
- Instant clones. Create a "release" dataset once, then
zfs cloneit per jail — new jails appear in milliseconds instead of extracting a multi-hundred-MB tarball each time. - Snapshots. Snapshot a jail before an upgrade or risky change; roll back in seconds if it goes wrong.
- Per-jail quotas.
zfs set quota=10G zroot/jails/containers/webserver. - Send/receive. Replicate jails to another host for backup or migration with
zfs send | zfs receive.
Example manual workflow:
# One-time: a clean "release" dataset from an extracted base.txz
zfs create zroot/jails/media/13.3-RELEASE
tar -xf base.txz -C /jails/media/13.3-RELEASE
zfs snapshot zroot/jails/media/13.3-RELEASE@clean
# Per new jail: instant clone
zfs clone zroot/jails/media/13.3-RELEASE@clean zroot/jails/containers/newjail
You can also delegate ZFS permissions into a jail (zfs jail, FreeBSD 13.2+) so the jail itself can manage a delegated dataset — useful for jails that need to create their own snapshots.
10. Using ezjail¶
ezjail is the oldest and most established third-party jail manager (a set of shell scripts), historically the default recommendation before iocage/bastille existed. It's simple, well-documented, and still maintained, though its zfs support is more manual than iocage's.
pkg install ezjail
sysrc ezjail_enable="YES"
# Fetch a base release (creates the "basejail" template)
ezjail-admin install -r 13.3-RELEASE
# Create a jail (thin: uses nullfs mounts back to the basejail, saving disk)
ezjail-admin create webserver 'em0|192.168.1.50'
# Start/stop
ezjail-admin start webserver
ezjail-admin stop webserver
# List
ezjail-admin list
# Get a shell
ezjail-admin console webserver
# Update all jails' base system when the host is patched
ezjail-admin update -u
ezjail jails are "thin" by default: /usr and other shared directories are nullfs-mounted read-only from a single basejail, so patching the basejail patches every thin jail's userland at once (see Section 18).
11. Using iocage¶
iocage is the most popular modern jail manager, written in Python, ZFS-native, and closely modeled conceptually on how tools like Docker present containers (templates, properties, snapshots) while remaining fully jail-native under the hood.
pkg install py39-iocage
iocage needs its own ZFS pool/dataset to manage:
iocage activate zroot
11.1 Fetch a release¶
iocage fetch -r 13.3-RELEASE
11.2 Create a jail¶
iocage create -n webserver -r 13.3-RELEASE ip4_addr="em0|192.168.1.50/24"
11.3 Manage¶
iocage list
iocage start webserver
iocage stop webserver
iocage console webserver # interactive login shell
iocage exec webserver "pkg install -y nginx"
iocage destroy webserver
11.4 Templates and cloning¶
iocage set template=yes webserver-template
iocage clone webserver-template -n webserver2
11.5 Snapshots¶
iocage snapshot webserver
iocage rollback webserver@<snapshot-name>
11.6 Properties (equivalent to jail.conf parameters, but managed as key/value pairs)¶
iocage set vnet=on webserver
iocage set memoryuse=512M webserver # resource limit via rctl
iocage get all webserver # list every property
iocage is generally the best starting point for anyone new to FreeBSD jails today, because it packages ZFS cloning, VNET setup, resource limits, and templating behind a single coherent CLI, closely mirroring the workflow people already know from Docker.
12. Using bastille¶
bastille is a newer manager, written to feel deliberately Docker-/Ansible-like (declarative "Bastillefile" provisioning scripts, an image/template model, a CLI verb structure very close to docker).
pkg install bastille
sysrc bastille_enable="YES"
bastille bootstrap 13.3-RELEASE # fetch a release
bastille create webserver 13.3-RELEASE 192.168.1.50
bastille start webserver
bastille stop webserver
bastille console webserver
bastille cmd webserver pkg install -y nginx
bastille list
bastille destroy webserver
A Bastillefile lets you script provisioning declaratively:
# Bastillefile
PKG nginx
CP files/nginx.conf /usr/local/etc/nginx/nginx.conf
SERVICE nginx enable
SERVICE nginx start
bastille bootstrap webserver Bastillefile
bastille also has strong built-in support for cloning ("thin" jails via ZFS), templates, and exporting/importing jails between hosts — a good choice if you like Docker-style ergonomics and want your jail definitions to live in source control.
13. Jails vs. Docker/Linux Containers¶
| FreeBSD Jails | Linux Containers (Docker/LXC) | |
|---|---|---|
| Isolation mechanism | Kernel-native jail(2) syscall + namespaces-like restrictions | Linux namespaces + cgroups |
| Kernel sharing | Shared host kernel | Shared host kernel |
| Filesystem model | Full userland copy (or nullfs-shared "thin" jail) or ZFS clone | Layered union filesystem (overlay2) images |
| Networking | Shared-stack (IP alias) or VNET (full stack) | Network namespace (always isolated) |
| Typical image size | Full base ~300–500MB (or shared via nullfs/ZFS clone → near-zero marginal cost) | Layered, often tens of MB per image with caching |
| Orchestration ecosystem | Smaller (iocage/bastille/ezjail, some k8s-adjacent tooling like iocage+cloud-init) |
Massive (Kubernetes, Swarm, Compose, registries) |
| Maturity | 25+ years, very stable, security-focused pedigree | ~12 years, huge ecosystem, faster-moving |
| Best fit | FreeBSD-native workloads, hosting providers, security-conscious isolation, ZFS-heavy environments | Cross-platform microservices, CI/CD, cloud-native workloads |
For someone coming from a Docker/Kubernetes background, the closest analogy is: jails are process/OS isolation like containers, but they think of themselves as "another FreeBSD install" rather than "a packaged application image." There's no Dockerfile/registry ecosystem baked into base FreeBSD — iocage and bastille are the tools that layer that experience on top.
14. Resource Limits (rctl/racct)¶
FreeBSD's resource limits and accounting (rctl(8)) can cap CPU, memory, number of processes, and more, on a per-jail basis — conceptually similar to cgroups limits for Docker.
14.1 Enable rctl¶
Add to /boot/loader.conf:
kern.racct.enable=1
Reboot (rctl cannot be enabled without a kernel racct-enabled build/toggle, hence the loader tunable rather than a live sysctl).
14.2 Example rules¶
rctl -a jail:webserver:memoryuse:deny=512M/jail
rctl -a jail:webserver:pcpu:deny=50/jail
rctl -a jail:webserver:maxproc:deny=200/jail
14.3 Persist rules¶
Add rules to /etc/rctl.conf so they survive reboot:
jail:webserver:memoryuse:deny=512M/jail
jail:webserver:pcpu:deny=50/jail
14.4 View usage/violations¶
rctl -u jail:webserver
rctl -h # show human-readable limits currently in effect
If you're using iocage, this is wrapped for you via properties like memoryuse, pcpu, maxproc, etc. — set with iocage set as shown in Section 11.6.
15. Persistent vs. Non-Persistent Jails¶
A jail with no running processes is normally destroyed automatically by the kernel. The persist parameter (in jail.conf, or jail -c persist) keeps a jail's kernel-level jail structure alive even when empty, so that:
- Jails with
exec.start/exec.stoplifecycle scripts behave predictably onservice jail restart - You can
jexecinto a jail that currently has no long-running daemon
Nearly all jail.conf-managed jails should set persist (as shown in the global block in Section 6.1) unless you deliberately want a one-shot jail that vanishes when its single command exits.
16. Updating and Patching Jails¶
16.1 Thin jails (nullfs-shared base, e.g. ezjail)¶
Patch once at the shared basejail level, and every thin jail inherits it immediately since /usr, /bin, etc. are read-only nullfs mounts back to that single copy:
ezjail-admin update -u -s
16.2 Thick jails / iocage / bastille (independent full copies)¶
Each jail must be patched individually, typically with freebsd-update run inside the jail (works because it operates purely on files, not on the running kernel):
iocage exec webserver freebsd-update fetch install
# or
bastille update webserver
16.3 Kernel/host updates¶
The kernel is shared, so a freebsd-update on the host that includes a kernel bump affects every jail simultaneously (jails automatically run against whatever kernel the host is booted into — there is no separate "jail kernel"). Keep jail userland releases reasonably close to the host's ABI level; running a very old jail release against a much newer host kernel is supported for a while (FreeBSD's kernel/userland ABI compatibility window) but isn't indefinite.
17. Security Hardening¶
- Least privilege by default. Leave
allow.*parameters off unless a specific jail genuinely needs them (e.g., only enableallow.sysvipcfor jails running PostgreSQL/Oracle-style software that needs SysV shared memory). devfsrulesets. Use a restrictivedevfs_rulesetso a jail can't see raw disk devices,/dev/mem, etc. FreeBSD ships ruleset 4 ("devfsrules_jail") as a reasonable default; customize further in/etc/devfs.rulesfor tighter jails.securelevel. Consider raisingkern.securelevelinside jails (viasysctlat jail start, or the base system's securelevel mechanism) to prevent even privileged jail processes from, e.g., loading kernel modules or altering firewall rules, though most of that is already blocked by the jail boundary itself.- Avoid
allow.raw_socketsunless the jail genuinely needs ping/traceroute — it's a common source of information leakage/DoS surface. - VNET + PF per jail for anything network-facing that you want to firewall independently rather than relying solely on host-level PF rules.
- ZFS snapshots before changes — cheap insurance; roll back a compromised or broken jail in seconds.
- Keep the base updated. A jail escape is rare given the design, but unpatched jailed services are the far more common real-world compromise vector — patch jails like you would patch any other host.
- Resource limits (
rctl) double as a security control: they blunt the impact of a runaway or compromised process inside one jail from starving the whole host.
18. Thick vs. Thin Jails¶
Thick jail: a completely independent, full copy of the base userland. Simple, fully self-contained, easy to reason about, but consumes full disk space per jail and must be patched individually. This is what iocage and bastille create by default (mitigated by ZFS clone-on-write, which makes the initial space cost near-zero even though logically each jail "owns" a full copy).
Thin jail: shares most of the base system read-only via nullfs mounts from a single "basejail," with only a small per-jail writable overlay (/etc, /var, /usr/local, etc.) unique to each jail. Patch the basejail once, and every thin jail inherits the fix immediately. This is ezjail's traditional default model.
With ZFS clones now cheap and ubiquitous, the practical disk-space argument for thin jails has weakened — a zfs clone costs almost nothing until data diverges — but thin jails still have a real advantage for patch management: with a nullfs-shared basejail, you truly patch once, whereas ZFS-cloned thick jails are independent copies from the moment they diverge and each still needs its own freebsd-update run.
19. Troubleshooting¶
Jail won't start / "jail: ... already exists": A previous jail with the same name/JID wasn't fully torn down. Check jls, and jail -r <name> to force-remove a stuck entry.
No network inside a shared-stack jail: Confirm the IP is actually aliased on the specified interface, and that ip4.addr in the jail config matches. Shared-stack jails cannot bind to 0.0.0.0 — services inside must be configured to bind to the jail's specific address.
"Operation not permitted" for things like mounting, raw sockets, sysctl changes: This is jail privilege restriction working as intended — either the operation genuinely shouldn't happen inside a jail, or you need to explicitly enable the relevant allow.* parameter for that jail.
VNET jail interface never appears / bridge issues: Confirm if_bridge/if_epair are loaded (kldstat), and that exec.prestart/exec.poststop hooks in jail.conf are actually creating/destroying the epair and adding it to the bridge — a common mistake is a typo in the bridge/epair name that causes silent failure.
freebsd-update inside a jail complains about the kernel: Pass --currently-running or ensure the jail's release matches something the installed kernel supports; a jail with a userland far newer than the host kernel can hit ABI mismatches for some tools (rare, but possible across many-major-version gaps).
Jail process shows on host ps but not top -j: Use ps -J <jid> or jexec <jid> ps aux — plain host ps/top without jail-aware flags mixes jailed and host processes together, which can be confusing when auditing.
20. Quick Reference Cheat Sheet¶
# Inspect
jls # list running jails
jls -v # verbose
ps -J <jid> # processes in a jail (host view)
# Manual, imperative
jail -c path=... ip4.addr=... command=/bin/sh
jexec <name> /bin/sh # shell into a running jail
jail -r <name> # stop/remove a jail
# jail.conf-driven (persistent, reboot-safe)
service jail start <name>
service jail stop <name>
service jail restart <name>
# iocage
iocage list
iocage create -n NAME -r 13.3-RELEASE ip4_addr="em0|IP/24"
iocage start|stop|console|destroy NAME
iocage snapshot NAME
iocage set memoryuse=512M NAME
# bastille
bastille create NAME RELEASE IP
bastille start|stop|console|destroy NAME
bastille cmd NAME <command>
# ezjail
ezjail-admin create NAME 'IFACE|IP'
ezjail-admin start|stop|console NAME
ezjail-admin update -u
# Resource limits
rctl -a jail:NAME:memoryuse:deny=512M/jail
rctl -u jail:NAME
# ZFS clone (fast jail creation)
zfs clone POOL/media/RELEASE@clean POOL/containers/NAME
Where to Go Next¶
man jail,man jail.conf,man rctl,man devfs.rules— the authoritative references- The FreeBSD Handbook's "Jails" chapter for the canonical walkthrough
iocageandbastilleproject docs for their respective declarative workflows- Poudriere's use of jails is worth studying separately if you're interested in reproducible package-building pipelines — it's one of the most sophisticated real-world uses of jails in the FreeBSD ecosystem