🔒 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

Video: Jails (FreeBSD video)
Prefer to watch? This short video runs this page's main commands on a real FreeBSD 15.0 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.

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:

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:

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

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

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

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>

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

⚠️ 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.)

Why was the web server sent to jail?<br>
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

← ZFS in Practice: Pools, Datasets, Snapshots and Self-Healing · ↑ FreeBSD Corner · The pf Firewall: Rules, Tables and Redirects →