FreeBSD jails have been the standard for OS-level isolation since FreeBSD 4.0, years before Linux containers existed. With FreeBSD 15, VNET gives each jail a fully independent network stack: its own interfaces, routing tables, and firewall state. No more sharing the host’s IP. Each jail behaves like a separate machine on the network.
We set up two FreeBSD jails with VNET networking on a real FreeBSD 15.0-RELEASE system, installed Nginx in one and PostgreSQL 17 in the other, connected them over the virtual bridge, and locked everything down with PF firewall rules. The ZFS clones that back these jails start at zero disk usage. Every command and output shown here was captured from that system. If you need to install FreeBSD 15 on KVM or Proxmox first, that guide covers the full installer walkthrough.
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
- 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 for your network). See our FreeBSD hostname and static IP guide if you need to configure networking first
- 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.
Three components make this work: a bridge interface on the host connecting to the physical NIC, epair interfaces acting as virtual Ethernet cables (one end on the host bridge, one end inside the jail), and the VNET kernel feature providing isolated network namespaces.
| 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. First verify VNET support is compiled into your kernel:
sysctl kern.features.vimage
A value of 1 confirms 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 is loaded:
kldstat | grep epair
It shows up in the kernel 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:
ifconfig bridge0 create
ifconfig bridge0 addm vtnet0 up
Persist the bridge in /etc/rc.conf:
sysrc cloned_interfaces+='bridge0'
sysrc ifconfig_bridge0='addm vtnet0 up'
Verify the bridge is active:
ifconfig bridge0
vtnet0 shows as a bridge member:
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 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 clone it 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:
zfs list -r zroot/jails
Both jails show 0B used because they share every block with the template until something changes inside the jail:
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
Configure jail.conf with VNET Networking
Each jail block in /etc/jail.conf defines VNET networking with epair interfaces that get created on start and destroyed on stop. Open the file:
vi /etc/jail.conf
Add the following configuration:
# 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 are running:
jls
Both jails are 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. For more detail, use the -h flag:
jls -h jid name host.hostname path vnet
The vnet column showing new confirms each jail has its own network namespace:
jid name host.hostname path vnet
1 webserver webserver /jails/containers/webserver new
3 database database /jails/containers/database new
Verify VNET Networking
Check that each jail sees only its own interface:
jexec webserver ifconfig epair1b
The webserver jail sees its epair 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
Internet connectivity from inside the jail:
jexec webserver ping -c 2 8.8.8.8
Packets flow through the bridge and out the host’s physical NIC:
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 works because we copied resolv.conf into the template:
jexec webserver host google.com
Forward and reverse lookups succeed:
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.
Jail-to-jail communication across the bridge:
jexec webserver ping -c 2 192.168.1.202
Sub-millisecond latency, as expected from a virtual bridge on the same host:
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
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:
jexec webserver sysrc nginx_enable=YES
jexec webserver service nginx start
Nginx starts cleanly 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:
jexec webserver fetch -qo - http://localhost/ | head -5
The Nginx welcome page confirms the web server is responding:
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
Install PostgreSQL in the Database Jail
Bootstrap pkg and install PostgreSQL 17 on FreeBSD:
jexec database env ASSUME_ALWAYS_YES=yes pkg bootstrap
jexec database pkg install -y postgresql17-server
Initialize the database cluster and start the service:
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 by allowing the subnet in pg_hba.conf and listening on all interfaces:
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"
The testdb database appears in the listing owned by testuser:
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:
jexec webserver pkg install -y postgresql17-client
Now 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();'
PostgreSQL 17.9 responds from the database jail over the VNET bridge:
version
----------------------------------------------------------------------------------------
PostgreSQL 17.9 on amd64-portbld-freebsd15.0, compiled by clang version 19.1.7, 64-bit
(1 row)
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) locks this down.
Create the PF configuration:
vi /etc/pf.conf
Add these 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
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 ruleset:
pfctl -s rules
PF shows the compiled rules with resolved addresses:
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
Day-to-day jail management uses a handful of commands.
Start, Stop, and Restart Jails
The service jail command handles lifecycle operations:
service jail start webserver
service jail stop webserver
service jail restart webserver
service jail start # starts all jails
Execute Commands Inside a Jail
Use jexec to run commands or open a shell:
jexec webserver /bin/sh # interactive shell
jexec webserver ps aux # list processes
jexec database service postgresql status
Install Packages in a Jail
Two syntax options, both work:
jexec webserver pkg install -y vim
pkg -j webserver install -y curl # alternative syntax
View Processes Per Jail
List processes running inside the webserver jail:
jexec webserver ps aux
The J flag in the STAT column confirms these processes are jailed:
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)
View Routing Tables Inside a Jail
Each jail has its own routing table, completely independent from the host:
jexec webserver netstat -rn
The default route points to the gateway through the jail’s epair interface:
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
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
The jail is now restricted to cores 0 and 1:
jail 4 mask: 0, 1
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: instant snapshots, even while the jail is running.
zfs snapshot zroot/jails/containers/webserver@before-update
zfs list -t snapshot -r zroot/jails/containers
The snapshot takes milliseconds and initially consumes zero additional space:
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 |
Thin jails with ZFS clones are the sweet spot for most multi-service setups. Service jails are new in FreeBSD 15 and designed for confining individual services with minimal overhead, configured in rc.conf rather than jail.conf. For a full overview of what changed in this release, see our FreeBSD 15 new features walkthrough.
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: add sysvshm = new, sysvmsg = new, and sysvsem = new to the jail block in /etc/jail.conf, then restart the jail. The = new value gives the jail its own isolated IPC namespace rather than sharing the host’s (= inherit), which is more secure.
Going Further
- BastilleBSD or AppJail – higher-level jail management with templates, declarative configs, and orchestration for 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 NullFS-based thin jails and the full parameter reference