🔒 Jails: Lightweight Containers, the BSD Way
Build a jail from scratch, start it, install a web server inside it, clone it with ZFS and see exactly what a jailed root cannot do
What a jail is
A jail is a part of the system fenced off with its own files, its own processes, its own hostname and its own IP address, but running on the same kernel as the machine that hosts it. Sound familiar? It is the same idea as a Linux container (Docker, LXC), and FreeBSD has had it in the base system for more than twenty years (from the documentation). The BSD version is delightfully plain:
- A jail is a folder with an operating system's files in it, plus a few lines of configuration.
- There are no images, no registry and no background daemon. The tools are jail, jls and jexec, and they are already installed.
- The user "root" inside a jail is not the real root. It can run services and manage files in its own world, and nothing more. You will see this in action below.
Everything on this page was run for real on FreeBSD 15.0-RELEASE in a virtual machine, with the ZFS layout from the other pages. Jails love ZFS, as you will see at the end.
Step 1: a home for the jail
The jail needs a folder, and with ZFS the best folder is its own dataset (see ZFS in Practice), so it can be snapshotted and cloned.
Then it needs an operating system to live in: the userland of FreeBSD (the "base" set, without a kernel, because the jail uses the host's kernel). It comes in an archive, base.txz. We had it on the installation disc, so no download was needed (on a normal server you can fetch it from the FreeBSD download site, or use bsdinstall jail PATH, which does it for you: from the documentation, not run here).
root@practice:~ # freebsd-version -kru 15.0-RELEASE 15.0-RELEASE 15.0-RELEASE root@practice:~ # mount_cd9660 /dev/cd0 /mnt root@practice:~ # ls /mnt/usr/freebsd-dist MANIFEST base.txz kernel.txz lib32.txz src.txz base-dbg.txz kernel-dbg.txz lib32-dbg.txz ports.txz tests.txz
/usr/freebsd-dist on the disc holds the sets. We want base.txz. Now the dataset and the extraction:
root@practice:~ # zfs create -o mountpoint=/jails zroot/jails root@practice:~ # zfs create zroot/jails/web root@practice:~ # zfs list -r zroot/jails NAME USED AVAIL REFER MOUNTPOINT zroot/jails 192K 12.6G 96K /jails zroot/jails/web 96K 12.6G 96K /jails/web root@practice:~ # tar -xf /mnt/usr/freebsd-dist/base.txz -C /jails/web root@practice:~ # ls /jails/web COPYRIGHT etc mnt root usr bin lib net sbin var boot libexec proc sys dev media rescue tmp root@practice:~ # du -sh /jails/web 378M /jails/web root@practice:~ # umount /mnt
zfs create -o mountpoint=/jails zroot/jails made a parent dataset mounted at /jails, and zroot/jails/web is the folder for our first jail. tar -xf base.txz -C /jails/web unpacked the whole base system into it: 378 MB, which looks like the root of a FreeBSD machine (bin, etc, usr, var...). That folder is the jail.
Step 2: describe it in /etc/jail.conf
root@practice:~ # cat > /etc/jail.conf <<'EOF'
> exec.start = "/bin/sh /etc/rc";
> exec.stop = "/bin/sh /etc/rc.shutdown";
> exec.clean;
> mount.devfs;
> path = "/jails/$name";
> host.hostname = "$name.example.org";
> interface = "vtnet0";
> web {
> ip4.addr = 10.0.2.50;
> }
> EOF
root@practice:~ # cat /etc/jail.conf
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
exec.clean;
mount.devfs;
path = "/jails/$name";
host.hostname = "$name.example.org";
interface = "vtnet0";
web {
ip4.addr = 10.0.2.50;
}
root@practice:~ # sysrc jail_enable=YES
jail_enable: NO -> YES
root@practice:~ # sysrc jail_list="web"
jail_list: -> web
root@practice:~ # cp /etc/resolv.conf /jails/web/etc/resolv.conf
The > characters are the shell asking for more lines of the heredoc, so what you see is the file being typed in, then cat shows it. What each line means:
- exec.start = "/bin/sh /etc/rc" and exec.stop say how the jail boots and shuts down: by running FreeBSD's own startup scripts inside it, so a jail starts its services just like a machine does.
- exec.clean gives the jail a clean environment, and mount.devfs gives it a minimal /dev.
- path = "/jails/$name": where the jail's files are. $name stands for the jail's name, so one line serves every jail you define.
- host.hostname: the hostname inside the jail.
- interface = "vtnet0" and ip4.addr = 10.0.2.50: the jail gets its own IP address, added as an alias on the host's network card.
- web { ... } defines the jail called web, with its own address.
Next, three settings: sysrc jail_enable=YES starts the jail service at boot, sysrc jail_list="web" lists which jails start with it, and the copy of /etc/resolv.conf into the jail gives it DNS settings.
Step 3: start it and look inside
root@practice:~ # service jail start web
Starting jails: web.
root@practice:~ # jls
JID IP Address Hostname Path
1 10.0.2.50 web.example.org /jails/web
root@practice:~ # jexec web hostname
web.example.org
root@practice:~ # jexec web freebsd-version
15.0-RELEASE
root@practice:~ # jexec web ps aux
USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND
root 1634 0.0 0.2 14432 3196 - ICsJ 00:00 0:00.00 /usr/sbin/syslogd -s
root 1637 0.0 0.1 14432 3064 - IJ 00:00 0:00.00 syslogd: syslogd.casper
root 1638 0.0 0.1 14432 3032 - IsJ 00:00 0:00.00 syslogd: system.net (sy
root 1702 0.0 0.1 14280 2708 - IsJ 00:00 0:00.00 /usr/sbin/cron -s
root 1756 0.0 0.2 14824 3152 0 R+J 00:00 0:00.00 ps aux
root@practice:~ # ps -J web
PID TT STAT TIME COMMAND
1634 - SCsJ 0:00.00 /usr/sbin/syslogd -s
1637 - IJ 0:00.00 syslogd: syslogd.casper (syslogd)
1638 - IsJ 0:00.00 syslogd: system.net (syslogd)
1702 - SsJ 0:00.00 /usr/sbin/cron -s
root@practice:~ # ifconfig vtnet0 | grep inet
inet 10.0.2.15 netmask 0xffffff00 broadcast 10.0.2.255
inet 10.0.2.50 netmask 0xffffffff broadcast 10.0.2.50
root@practice:~ # jexec web ifconfig vtnet0 | grep inet
inet 10.0.2.50 netmask 0xffffffff broadcast 10.0.2.50
- service jail start web started it, and jls (jail list) shows it: a job ID, the IP address, the hostname and the path.
- jexec web COMMAND runs a command inside the jail. hostname answers web.example.org, and freebsd-version says 15.0-RELEASE. The jail's userland is whatever is in its folder.
- jexec web ps aux shows only four processes: the jail's own syslogd and cron, plus the ps itself. The jail cannot see the host's processes. (The J at the end of each status is the flag for "in a jail".)
- The host sees them too: ps -J web lists the jail's processes from outside.
- Compare the two ifconfig calls. The host's vtnet0 now has two addresses: its own 10.0.2.15 and the alias 10.0.2.50 that the jail uses. Inside the jail, ifconfig shows only 10.0.2.50, because the jail is allowed to see only its own address.
Step 4: what a jailed root cannot do
root@practice:~ # jexec web kldload zfs kldload: can't load zfs: Operation not permitted root@practice:~ # jexec web ifconfig vtnet0 inet 10.0.2.99/24 alias ifconfig: ioctl (SIOCAIFADDR): Operation not permitted root@practice:~ # jexec web date 200001010000 date: clock_settime: Operation not permitted root@practice:~ # jexec web sysctl security.jail.jailed security.jail.jailed: 1 root@practice:~ # sysctl security.jail.jailed security.jail.jailed: 0 root@practice:~ # jexec web ls /jails ls: /jails: No such file or directory root@practice:~ # ls /jails/web/etc | head -3 X11 aliases authpf
- kldload zfs (load a kernel driver): Operation not permitted. The kernel belongs to the host.
- Adding a network address, ifconfig ... alias: Operation not permitted. The jail's network settings are fixed by the host's configuration.
- Setting the clock: Operation not permitted. There is only one clock, and it belongs to the host.
- sysctl security.jail.jailed answers 1 inside, and 0 on the host: a program can ask whether it is in a jail.
- ls /jails from inside says there is no such folder: the jail sees its own folder as /, and cannot even find the place where it lives. From the host the same files are /jails/web/etc and so on.
That is the point of a jail: if someone breaks into the web server inside it, they land in a small room with the doors locked, not in your server. (A jail is a strong fence, but it is not a magic wall: keep the host updated, and be careful about what you give a jail access to: from the documentation, not run here.)
Step 5: install and run a service inside
You manage software in a jail from the host, with the -j option of the tools you know. First the host needs pkg, and then:
⚠️ Newer package repository (checked October 2026): the package repository now targets FreeBSD 15.1, while 15.0-RELEASE is end of life. On a 15.0 machine pkg may stop with Newer FreeBSD version for package ... [y/N], and the version numbers in the output below may differ from yours. To carry on, run export IGNORE_OSVERSION=yes first, or better, move to a supported release (see the upgrading guide).
root@practice:~ # export IGNORE_OSVERSION=yes root@practice:~ # pkg -v The package management tool is not yet installed on your system. Do you want to fetch and install it now? [y/N]: y Bootstrapping pkg from pkg+https://pkg.FreeBSD.org/FreeBSD:15:amd64/quarterly, please wait... Verifying signature with trusted certificate pkg.freebsd.org.2013102301... done Installing pkg-2.8.4... Extracting pkg-2.8.4: 100% 2.8.4 root@practice:~ # pkg -j web install -y nginx Updating FreeBSD-ports repository catalogue... [web.example.org] Fetching meta.conf: 100% 168 B 0.2 kB/s 00:01 [web.example.org] Fetching data: 100% 9296 KiB 1.2 MB/s 00:08 Processing entries: 100% FreeBSD-ports repository update completed. 38288 packages processed. Updating FreeBSD-ports-kmods repository catalogue... [web.example.org] Fetching meta.conf: 100% 179 B 0.2 kB/s 00:01 [web.example.org] Fetching data: 100% 35 KiB 35.9 kB/s 00:01 Processing entries: 100% FreeBSD-ports-kmods repository update completed. 240 packages processed. All repositories are up to date. The following 2 package(s) will be affected (of 0 checked): New packages to be INSTALLED: nginx: 1.30.6,3 [FreeBSD-ports] pcre2: 10.49 [FreeBSD-ports] Number of packages to be installed: 2 The process will require 10 MiB more space. 2 MiB to be downloaded. [web.example.org] [1/2] Fetching nginx-1.30.6,3: 100% 588 KiB 200.8 kB/s 00:03 [web.example.org] [2/2] Fetching pcre2-10.49: 100% 1487 KiB 1.5 MB/s 00:01 Checking integrity... done (0 conflicting) [web.example.org] [1/2] Installing pcre2-10.49... [web.example.org] [1/2] Extracting pcre2-10.49: 100% [web.example.org] [2/2] Installing nginx-1.30.6,3... ===> Creating groups Using existing group 'www' ===> Creating users Using existing user 'www' [web.example.org] [2/2] Extracting nginx-1.30.6,3: 100% [... nginx notes left out to keep this page short ...]
pkg -j web install -y nginx installed the web server into the jail, not the host: the lines are prefixed with [web.example.org]. The jail has its own package catalogue. Then the usual FreeBSD service steps, also with -j:
root@practice:~ # sysrc -j web nginx_enable=YES nginx_enable: -> YES root@practice:~ # service -j web nginx start Performing sanity check on nginx configuration: nginx: the configuration file /usr/local/etc/nginx/nginx.conf syntax is ok nginx: configuration file /usr/local/etc/nginx/nginx.conf test is successful Starting nginx. root@practice:~ # sockstat -4l | grep nginx www nginx 2095 6 tcp4 10.0.2.50:80 *:* root nginx 2094 6 tcp4 10.0.2.50:80 *:* root@practice:~ # fetch -qo - http://10.0.2.50/ | head -4 <!DOCTYPE html> <html> <head> <title>Welcome to nginx!</title> root@practice:~ # pkg info pkg-2.8.4 Package manager root@practice:~ # pkg -j web info nginx-1.30.6,3 Robust and small WWW server pcre2-10.49 Perl Compatible Regular Expressions library, version 2
- sysrc -j web nginx_enable=YES wrote the setting into the jail's /etc/rc.conf, and service -j web nginx start started it.
- From the host, sockstat shows nginx listening on 10.0.2.50:80: the jail's own address, not *:80. Each jail can run its own web server on port 80 without clashing with the others.
- fetch -qo - (the base system's download tool) got the "Welcome to nginx!" page from the jail.
- pkg info on the host lists only pkg itself, and pkg -j web info lists nginx and the library it needs. The host stays clean, which is a big part of why people use jails.
Step 6: ZFS makes jails almost free
A second web server, identical to the first? Snapshot the jail and clone it:
root@practice:~ # zfs snapshot zroot/jails/web@fresh
root@practice:~ # zfs clone zroot/jails/web@fresh zroot/jails/web2
root@practice:~ # zfs list -r -o name,used,refer,origin zroot/jails
NAME USED REFER ORIGIN
zroot/jails 427M 96K -
zroot/jails/web 427M 427M -
zroot/jails/web2 8K 427M zroot/jails/web@fresh
root@practice:~ # cat >> /etc/jail.conf <<'EOF'
> web2 {
> ip4.addr = 10.0.2.51;
> }
> EOF
root@practice:~ # service jail start web2
Starting jails: web2.
root@practice:~ # jls
JID IP Address Hostname Path
1 10.0.2.50 web.example.org /jails/web
2 10.0.2.51 web2.example.org /jails/web2
root@practice:~ # jexec web2 hostname
web2.example.org
root@practice:~ # fetch -qo - http://10.0.2.51/ | head -3
<!DOCTYPE html>
<html>
<head>
- zfs snapshot froze the jail's dataset, and zfs clone made zroot/jails/web2 from it. Look at the USED column: 8K for the new jail's dataset, while it holds the full 427M system, because it shares every block with the original until something changes.
- We added a web2 block to /etc/jail.conf with another address, and service jail start web2 started it. jls shows both jails running.
- The clone started nginx by itself, because the clone's rc.conf already said nginx_enable=YES, and fetch found the nginx page at 10.0.2.51.
Cloning a jail takes a second and almost no space. That is how people build many jails (or test a change on a copy of one) with ease.
root@practice:~ # service jail stop
Stopping jails: web.
root@practice:~ # jls
JID IP Address Hostname Path
2 10.0.2.51 web2.example.org /jails/web2
A last gotcha: service jail stop stopped only web. It follows jail_list="web" from /etc/rc.conf, and we started web2 by hand, so web2 kept running (jls still lists it). Stop a single jail with service jail stop web2, or add all your jails to jail_list (from the documentation, not run here, beyond what you see above).
Jails and Linux containers
- No images, no daemon: a jail is a folder and a config. A Docker image is a layered archive pulled by a daemon.
- Own userland, shared kernel: the same as Linux containers. The jail's FreeBSD version should not be newer than the host's kernel (from the documentation). Here both are 15.0-RELEASE.
- Updating: a jail's userland is updated with freebsd-update -b /jails/web ... or with the -j option, in the same way as the host (from the documentation, not run here), after you have updated the host.
- Networking: we used the host's network with an alias address. FreeBSD can also give each jail its own virtual network stack (a "VNET jail") with its own firewall and routes. That is the more advanced setup (from the documentation, not run here).
- Limits: CPU, memory and process limits come from rctl (from the documentation, not run here).
- Tools: many people manage jails with helper programs (bastille, iocage, pot) that automate everything above. Learn the plain way first and those programs will make sense.
⚠️ Do not delete a jail's folder with rm: if the jail is a ZFS dataset, remove it properly with service jail stop NAME and then zfs destroy zroot/jails/NAME (and its clones first, if any). Deleting while the jail is running, or a clone's origin snapshot, gives confusing errors. (From the documentation, not run here.)
It kept listening on every interface.<br>
Now it is only allowed one address.
🧪 Try it yourself
In a FreeBSD virtual machine with ZFS:
1. Create a dataset for a jail called test and unpack base.txz into it (from the install disc, or downloaded). 2. Write a block for it in /etc/jail.conf with its own IP address, and start it. 3. Run jls, then jexec test hostname and jexec test ps aux. 4. Try jexec test kldload zfs and jexec test date 200001010000. What do you get? 5. Install a package in it with pkg -j test install, enable it with sysrc -j test, start it with service -j test, and fetch its page from the host. 6. Snapshot and clone the jail, give the clone another address and start it. 7. Stop both jails, and destroy the clone's dataset, its snapshot and the jail.
The cheat sheet
- Create: zfs create zroot/jails/NAME, unpack base.txz into it, copy /etc/resolv.conf.
- Configure: a block in /etc/jail.conf with ip4.addr, path, exec.start, exec.stop.
- Boot at start: sysrc jail_enable=YES and sysrc jail_list="NAME ...".
- Run it: service jail start NAME, service jail stop NAME, jls.
- Inside: jexec NAME COMMAND, ps -J NAME, sockstat -4l on the host.
- Software: pkg -j NAME install PKG, sysrc -j NAME VAR=VALUE, service -j NAME SERVICE start.
- Clone: zfs snapshot zroot/jails/NAME@x, zfs clone zroot/jails/NAME@x zroot/jails/NEW.
- A jailed root cannot: load kernel modules, change the network or clock, or see the host.
← ZFS in Practice: Pools, Datasets, Snapshots and Self-Healing · ↑ FreeBSD Corner · The pf Firewall: Rules, Tables and Redirects →