FreeBSD

FreeBSD Jails with VNET Networking on FreeBSD 15

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.

Original content from computingforgeeks.com - post 164432

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.

ComponentHost SideJail Side
Physical NICvtnet0 (member of bridge0)Not visible
Bridgebridge0 (connects everything)Not visible
Virtual cableepairNa (on bridge0)epairNb (jail’s interface)
IP addressHost IP via DHCP/staticJail’s own static IP
Routing tableHost routesJail’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, initdb fails with FATAL: could not create shared memory segment: Function not implemented
  • allow.raw_sockets lets jails use ping and 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:

TypeDisk UsageIsolationBest 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=1 in /boot/loader.conf and use rctl to 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

Related Articles

Databases Install and Configure PostgreSQL 17 on FreeBSD 14 Security 6 Benefits of Enlisting Cybersecurity Services for Your Business Security How To Improve Account Security In 2024 Security Install Metasploit Framework on CentOS 9/8/7

Leave a Comment

Press ESC to close