Andrew Mercer
on this page

FreeBSD Jails: A Comprehensive Guide

Table of Contents

  1. What Jails Are and Why They Exist
  2. Core Concepts
  3. Preparing the Host
  4. Creating a Jail Manually
  5. Managing Jails with jail(8) and jls(8)
  6. Declarative Jails with /etc/jail.conf
  7. Networking Models
  8. VNET Jails (Virtualized Networking)
  9. ZFS Integration
  10. Using ezjail
  11. Using iocage
  12. Using bastille
  13. Jails vs. Docker/Linux Containers
  14. Resource Limits (rctl/racct)
  15. Persistent vs. Non-Persistent Jails
  16. Updating and Patching Jails
  17. Security Hardening
  18. Thick vs. Thin Jails
  19. Troubleshooting
  20. 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 listing
  • jexec <jid-or-name> <command> — run a command inside a running jail, e.g. jexec webserver /bin/sh to get an interactive shell
  • jail -r <jid-or-name> — remove/stop a jail
  • jail -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 VIMAGE support (the GENERIC kernel has included this since FreeBSD 12; verify with sysctl kern.conftxt | grep VIMAGE or check sysctl -a | grep vnet)
  • if_bridge and if_epair kernel modules (usually loadable, sometimes need kldload 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 clone it 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.stop lifecycle scripts behave predictably on service jail restart
  • You can jexec into 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 enable allow.sysvipc for jails running PostgreSQL/Oracle-style software that needs SysV shared memory).
  • devfs rulesets. Use a restrictive devfs_ruleset so 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.rules for tighter jails.
  • securelevel. Consider raising kern.securelevel inside jails (via sysctl at 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_sockets unless 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
  • iocage and bastille project 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