FreeBSD jails have been the gold standard for OS-level isolation since FreeBSD 4.0, long before Linux containers existed. With FreeBSD 15, jails get even better: descriptor-based jail operations eliminate race conditions, and VNET gives each jail a fully independent network stack instead of sharing the host’s IP. That changes the game for multi-service deployments where you need real network isolation between workloads.
This guide walks through setting up FreeBSD jails with VNET networking on FreeBSD 15.0. We build two jails from scratch using ZFS clones, give each one its own network interface via epair and bridge, install Nginx in one and PostgreSQL 17 in the other, then lock everything down with PF firewall rules. Every command was tested on a real FreeBSD 15.0-RELEASE system running on Proxmox VE with ZFS.
Verified working: March 2026 on FreeBSD 15.0-RELEASE (amd64), OpenZFS 2.4.0, pkg 2.5.1
Prerequisites
- FreeBSD 15.0-RELEASE installed with ZFS root (see our FreeBSD 15 installation guide on Proxmox/KVM)
- At least 4 GB RAM and 40 GB disk (jails share the base via ZFS clones, so disk usage is minimal)
- Root access to the host system
- A working network connection with a gateway at 192.168.1.1 (adjust IPs for your network)
- Tested on: FreeBSD 15.0-RELEASE, ZFS 2.4.0-rc4, 4 CPU cores, 4 GB RAM
How FreeBSD Jails with VNET Work
Standard jails share the host’s network stack and bind to specific IP addresses. VNET jails get their own complete network stack, including routing tables, firewall state, and interfaces. Each jail sees only its own epair interface, not the host’s physical NIC.
The architecture uses three components: a bridge interface on the host that connects to the physical NIC, epair interfaces that act as virtual Ethernet cables (one end on the host bridge, one end inside the jail), and the VNET kernel feature that gives each jail an isolated network namespace.
| Component | Host Side | Jail Side |
|---|---|---|
| Physical NIC | vtnet0 (member of bridge0) | Not visible |
| Bridge | bridge0 (connects everything) | Not visible |
| Virtual cable | epairNa (on bridge0) | epairNb (jail’s interface) |
| IP address | Host IP via DHCP/static | Jail’s own static IP |
| Routing table | Host routes | Jail’s independent routes |
Load the Epair Kernel Module
The if_epair module creates virtual Ethernet pairs. One end stays on the host, the other moves into the jail. Verify that VNET support is compiled into your kernel first:
sysctl kern.features.vimage
The output should confirm VNET is available:
kern.features.vimage: 1
Load the epair module and make it persistent across reboots:
kldload if_epair
echo 'if_epair_load="YES"' >> /boot/loader.conf
Confirm the module loaded:
kldstat | grep epair
You should see it in the module list:
8 1 0xffffffff8302b000 539c if_epair.ko
Create the Bridge Interface
The bridge connects your physical NIC to the virtual epair interfaces. Create it and add your primary network interface as a member:
ifconfig bridge0 create
ifconfig bridge0 addm vtnet0 up
Make the bridge persistent in /etc/rc.conf:
sysrc cloned_interfaces+='bridge0'
sysrc ifconfig_bridge0='addm vtnet0 up'
Verify the bridge is active with vtnet0 as a member:
ifconfig bridge0
The output confirms vtnet0 is attached to the bridge:
bridge0: flags=1008843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST,LOWER_UP> metric 0 mtu 1500
ether 58:9c:fc:10:76:37
id 00:00:00:00:00:00 priority 32768 hellotime 2 fwddelay 15
maxage 20 holdcnt 6 proto rstp maxaddr 2000 timeout 1200
member: vtnet0 flags=143<LEARNING,DISCOVER,AUTOEDGE,AUTOPTP>
port 1 priority 128 path cost 2000
Build the Jail Filesystem with ZFS Clones
FreeBSD jails need a root filesystem containing the FreeBSD userland. You could extract the base system into each jail directory (thick jails), but ZFS clones are far more efficient. A template snapshot holds the full base system once, and each jail gets an instant copy-on-write clone that starts at zero extra disk usage.
Create the ZFS dataset structure:
zfs create -p zroot/jails/templates
zfs create zroot/jails/containers
zfs set mountpoint=/jails zroot/jails
Download and extract the FreeBSD 15.0 base system into the template:
zfs create zroot/jails/templates/base
fetch -o /tmp/base.txz https://download.freebsd.org/releases/amd64/15.0-RELEASE/base.txz
tar -xf /tmp/base.txz -C /jails/templates/base
Copy the host’s DNS resolver and timezone into the template so jails can resolve hostnames immediately:
cp /etc/resolv.conf /jails/templates/base/etc/
cp /etc/localtime /jails/templates/base/etc/
Snapshot the template and create clones for each jail:
zfs snapshot zroot/jails/templates/[email protected]
zfs clone zroot/jails/templates/[email protected] zroot/jails/containers/webserver
zfs clone zroot/jails/templates/[email protected] zroot/jails/containers/database
Check the disk usage. Both jails reference the same 374 MB template and consume zero additional space:
zfs list -r zroot/jails
The output shows the power of ZFS cloning:
NAME USED AVAIL REFER MOUNTPOINT
zroot/jails 374M 33.9G 104K /jails
zroot/jails/containers 96K 33.9G 96K /jails/containers
zroot/jails/containers/database 0B 33.9G 374M /jails/containers/database
zroot/jails/containers/webserver 0B 33.9G 374M /jails/containers/webserver
zroot/jails/templates 374M 33.9G 96K /jails/templates
zroot/jails/templates/base 374M 33.9G 374M /jails/templates/base
Both jails show 0B used because they share every block with the template until something changes inside the jail.
Configure jail.conf with VNET Networking
The central configuration file is /etc/jail.conf. Each jail block defines VNET networking with epair interfaces that get created on start and destroyed on stop. Open the configuration file:
vi /etc/jail.conf
Add the following configuration for two VNET jails:
# Global settings
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
exec.clean;
mount.devfs;
allow.raw_sockets;
exec.consolelog = "/var/log/jail_${name}_console.log";
$bridge = "bridge0";
webserver {
host.hostname = "webserver";
path = "/jails/containers/webserver";
vnet;
vnet.interface = "epair1b";
exec.prestart = "/sbin/ifconfig epair1 create up";
exec.prestart += "/sbin/ifconfig bridge0 addm epair1a up";
exec.start += "/sbin/ifconfig epair1b 192.168.1.201/24 up";
exec.start += "/sbin/route add default 192.168.1.1";
exec.poststop = "/sbin/ifconfig bridge0 deletem epair1a";
exec.poststop += "/sbin/ifconfig epair1a destroy";
}
database {
host.hostname = "database";
path = "/jails/containers/database";
# PostgreSQL needs SysV shared memory
sysvshm = new;
sysvmsg = new;
sysvsem = new;
vnet;
vnet.interface = "epair2b";
exec.prestart = "/sbin/ifconfig epair2 create up";
exec.prestart += "/sbin/ifconfig bridge0 addm epair2a up";
exec.start += "/sbin/ifconfig epair2b 192.168.1.202/24 up";
exec.start += "/sbin/route add default 192.168.1.1";
exec.poststop = "/sbin/ifconfig bridge0 deletem epair2a";
exec.poststop += "/sbin/ifconfig epair2a destroy";
}
Key points in this configuration:
- vnet enables the virtual network stack for each jail
- exec.prestart creates the epair interface and attaches the host end (epairNa) to the bridge before the jail starts
- exec.start configures the jail end (epairNb) with an IP and default route inside the jail
- exec.poststop tears down the epair after the jail stops, keeping the host clean
- sysvshm/sysvmsg/sysvsem = new gives the database jail its own SysV IPC namespace, which PostgreSQL requires for shared memory. Without this,
initdbfails withFATAL: could not create shared memory segment: Function not implemented - allow.raw_sockets lets jails use
pingand other ICMP tools for troubleshooting
Start the Jails
Enable the jail service and start both jails:
sysrc jail_enable=YES
sysrc jail_parallel_start=YES
service jail start
Setting jail_parallel_start launches all jails concurrently instead of sequentially, which matters when you have dozens of jails.
Verify both jails are running:
jls
You should see both jails listed with their paths:
JID IP Address Hostname Path
1 webserver /jails/containers/webserver
2 database /jails/containers/database
The IP Address column is empty because VNET jails manage their own networking internally. Use jls -h for more detail:
jls -h jid name host.hostname path vnet
jid name host.hostname path vnet
1 webserver webserver /jails/containers/webserver new
3 database database /jails/containers/database new
The vnet column showing new confirms each jail has its own network namespace.
Verify VNET Networking
Check that each jail sees only its own interface and can reach the internet:
jexec webserver ifconfig epair1b
The webserver jail sees its epair interface at 192.168.1.201:
epair1b: flags=1008843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST,LOWER_UP> metric 0 mtu 1500
ether 58:9c:fc:10:ef:29
inet 192.168.1.201 netmask 0xffffff00 broadcast 192.168.1.255
groups: epair
media: Ethernet 10Gbase-T (10Gbase-T <full-duplex>)
status: active
Test internet connectivity from the webserver jail:
jexec webserver ping -c 2 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=114 time=24.730 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=114 time=23.707 ms
--- 8.8.8.8 ping statistics ---
2 packets transmitted, 2 packets received, 0.0% packet loss
DNS resolution also works because we copied resolv.conf into the template:
jexec webserver host google.com
google.com has address 142.251.47.174
google.com has IPv6 address 2a00:1450:401a:800::200e
google.com mail is handled by 10 smtp.google.com.
Test jail-to-jail communication. The webserver can reach the database at 192.168.1.202:
jexec webserver ping -c 2 192.168.1.202
PING 192.168.1.202 (192.168.1.202): 56 data bytes
64 bytes from 192.168.1.202: icmp_seq=0 ttl=64 time=0.133 ms
64 bytes from 192.168.1.202: icmp_seq=1 ttl=64 time=0.170 ms
--- 192.168.1.202 ping statistics ---
2 packets transmitted, 2 packets received, 0.0% packet loss
Sub-millisecond latency between jails on the same host, which is what you would expect from a virtual bridge.
Install Nginx in the Webserver Jail
Bootstrap the package manager and install Nginx:
jexec webserver env ASSUME_ALWAYS_YES=yes pkg bootstrap
jexec webserver pkg install -y nginx
Enable and start Nginx inside the jail:
jexec webserver sysrc nginx_enable=YES
jexec webserver service nginx start
Nginx starts successfully inside the jail:
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.
Verify by fetching the default page from the host:
jexec webserver fetch -qo - http://localhost/ | head -5
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
Install PostgreSQL in the Database Jail
Bootstrap pkg and install PostgreSQL 17:
jexec database env ASSUME_ALWAYS_YES=yes pkg bootstrap
jexec database pkg install -y postgresql17-server
Initialize the database cluster and start PostgreSQL:
jexec database sysrc postgresql_enable=YES
jexec database service postgresql initdb
jexec database service postgresql start
Configure PostgreSQL to accept connections from the webserver jail. Edit the configuration:
jexec database sh -c "echo \"listen_addresses = '*'\" >> /var/db/postgres/data17/postgresql.conf"
jexec database sh -c "echo \"host all all 192.168.1.0/24 md5\" >> /var/db/postgres/data17/pg_hba.conf"
jexec database service postgresql restart
Create a test user and database:
jexec database su -m postgres -c "createuser -s testuser"
jexec database su -m postgres -c "psql -c \"ALTER USER testuser PASSWORD 'testpass';\""
jexec database su -m postgres -c "createdb -O testuser testdb"
Confirm the database was created:
jexec database su -m postgres -c "psql -l"
List of databases
Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges
-----------+----------+----------+-----------------+---------+---------+--------+-----------+-----------------------
postgres | postgres | UTF8 | libc | C | C.UTF-8 | | |
template0 | postgres | UTF8 | libc | C | C.UTF-8 | | | =c/postgres +
| | | | | | | | postgres=CTc/postgres
template1 | postgres | UTF8 | libc | C | C.UTF-8 | | | =c/postgres +
| | | | | | | | postgres=CTc/postgres
testdb | testuser | UTF8 | libc | C | C.UTF-8 | | |
(4 rows)
Test Cross-Jail Database Connectivity
Install the PostgreSQL client in the webserver jail and connect to the database jail over the VNET network:
jexec webserver pkg install -y postgresql17-client
Connect from the webserver jail to the database jail at 192.168.1.202:
PGPASSWORD=testpass jexec webserver /usr/local/bin/psql -h 192.168.1.202 -U testuser -d testdb -c 'SELECT version();'
version
----------------------------------------------------------------------------------------
PostgreSQL 17.9 on amd64-portbld-freebsd15.0, compiled by clang version 19.1.7, 64-bit
(1 row)
The webserver jail successfully queries PostgreSQL 17.9 running in the database jail over the VNET bridge. This is real network traffic flowing through the epair interfaces, not a loopback shortcut.
Secure Jails with PF Firewall Rules
VNET gives each jail its own network stack, but without firewall rules, every jail can reach every other jail and the internet unrestricted. PF (Packet Filter) lets you control what traffic flows between jails and the outside world.
Create /etc/pf.conf:
vi /etc/pf.conf
Add the following rules:
# Macros
ext_if = "vtnet0"
bridge_if = "bridge0"
jail_webserver = "192.168.1.201"
jail_database = "192.168.1.202"
# Options
set skip on lo0
# NAT for jails
nat on $ext_if from { $jail_webserver, $jail_database } to any -> ($ext_if)
# Default deny inbound
block in log all
pass out all keep state
# Allow SSH to host
pass in on $ext_if proto tcp to any port 22
# Allow HTTP/HTTPS to webserver jail only
pass in on $ext_if proto tcp to $jail_webserver port { 80, 443 }
# Allow PostgreSQL only from webserver jail
pass in on $bridge_if proto tcp from $jail_webserver to $jail_database port 5432
# Allow ICMP for diagnostics
pass inet proto icmp all
These rules ensure the database jail is only reachable from the webserver jail on port 5432. No direct external access to PostgreSQL.
Enable and load PF:
sysrc pf_enable=YES
kldload pf
pfctl -f /etc/pf.conf
pfctl -e
Verify the active rules:
pfctl -s rules
block drop in log all
pass in on vtnet0 inet proto tcp from any to 192.168.1.201 port = http flags S/SA keep state
pass in on vtnet0 inet proto tcp from any to 192.168.1.201 port = https flags S/SA keep state
pass in on vtnet0 proto tcp from any to any port = ssh flags S/SA keep state
pass in on bridge0 inet proto tcp from 192.168.1.201 to 192.168.1.202 port = postgresql flags S/SA keep state
pass out all flags S/SA keep state
pass inet proto icmp all keep state
Jail Management Operations
Here are the essential commands for day-to-day jail management.
Start, Stop, and Restart Jails
service jail start webserver
service jail stop webserver
service jail restart webserver
service jail start # starts all jails
Execute Commands Inside a Jail
jexec webserver /bin/sh # interactive shell
jexec webserver ps aux # list processes
jexec database service postgresql status
Install Packages in a Jail
jexec webserver pkg install -y vim
pkg -j webserver install -y curl # alternative syntax
View Processes Per Jail
jexec webserver ps aux
USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND
root 2379 0.0 0.1 14432 3220 - SCsJ 01:08 0:00.00 /usr/sbin/syslogd -s
root 2502 0.0 0.1 14280 2752 - SsJ 01:08 0:00.00 /usr/sbin/cron -s
root 3422 0.0 0.3 24844 10920 - IsJ 01:09 0:00.00 nginx: master process /usr/local/sbin/nginx
www 3423 0.0 0.3 24844 11400 - IJ 01:09 0:00.00 nginx: worker process (nginx)
The J flag in the STAT column confirms these processes are running inside a jail.
View Routing Tables Inside a Jail
jexec webserver netstat -rn
Routing tables
Internet:
Destination Gateway Flags Netif Expire
default 192.168.1.1 UGS epair1b
192.168.1.0/24 link#5 U epair1b
192.168.1.201 link#10 UHS lo0
Each jail has its own routing table, completely independent from the host.
CPU Pinning with cpuset
Pin a jail to specific CPU cores to prevent it from consuming all host resources:
cpuset -l 0-1 -j 4
Verify the assignment:
cpuset -g -j 4
jail 4 mask: 0, 1
This restricts the jail to cores 0 and 1 only. Replace the jail ID (4) with your actual JID from jls.
ZFS Snapshots of Running Jails
One of the biggest advantages of ZFS-backed jails is instant snapshots, even while the jail is running:
zfs snapshot zroot/jails/containers/webserver@before-update
zfs list -t snapshot -r zroot/jails/containers
NAME USED AVAIL REFER MOUNTPOINT
zroot/jails/containers/webserver@snap1 0B - 466M -
If a pkg upgrade breaks something inside a jail, roll back instantly with zfs rollback.
Thick Jails vs Thin Jails vs Service Jails
FreeBSD 15 supports three jail types. This guide uses thin jails (ZFS clones), but here is how they compare:
| Type | Disk Usage | Isolation | Best For |
|---|---|---|---|
| Thick jail | ~375 MB per jail (full copy) | Complete (own base system) | Maximum isolation, different base versions |
| Thin jail (ZFS clone) | ~0 MB initial (copy-on-write) | High (shared base, own packages) | Running many jails efficiently |
| Service jail (FreeBSD 15+) | 0 MB (shares host filesystem) | Minimal (shares host base) | Isolating individual daemons quickly |
Service jails are new in FreeBSD 15 and designed for confining individual services with minimal overhead. They share the host filesystem directly and are configured in rc.conf rather than jail.conf. Thin jails with ZFS clones are the sweet spot for most multi-service setups.
What Happens When PostgreSQL Runs Without SysV IPC
If you forget to add sysvshm = new to a jail that runs PostgreSQL, initdb fails with this error:
2026-03-25 01:19:14.773 UTC [4438] FATAL: could not create shared memory segment: Function not implemented
2026-03-25 01:19:14.773 UTC [4438] DETAIL: Failed system call was shmget(key=59615, size=56, 03600).
child process exited with exit code 1
initdb: removing data directory "/var/db/postgres/data17"
The fix is adding sysvshm = new, sysvmsg = new, and sysvsem = new to the jail block in /etc/jail.conf, then restarting the jail. The = new value gives the jail its own isolated IPC namespace rather than sharing the host’s (which would be = inherit and is less secure).
Going Further
- BastilleBSD or AppJail provide higher-level jail management with templates, declarative configs, and orchestration if you are managing dozens of jails
- Resource limits with rctl: enable
kern.racct.enable=1in/boot/loader.confand userctlto cap CPU, memory, and disk I/O per jail - VLAN-tagged jails: create VLAN interfaces on the bridge to segment jail traffic at layer 2
- For production, consider adding ZFS encryption per-jail and automated snapshot schedules with
zfs-auto-snapshot - The FreeBSD Handbook jails chapter covers additional topics including NullFS-based thin jails and Jail parameters reference