FreeBSD

FreeBSD Jails with VNET Networking on FreeBSD 15

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.

Original content from computingforgeeks.com - post 164432

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.

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. 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, 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 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:

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

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=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 NullFS-based thin jails and the full parameter reference

Related Articles

Arch Linux Extract Website data, urls, emails, files and accounts using Photon crawler Jenkins Configure Jenkins behind Nginx and Let’s Encrypt SSL Databases How To Setup SSH and MySQL Bastion Server using Warpgate CentOS Install OpenSSL 3.x from Source on RHEL 10 / Rocky Linux 10 / AlmaLinux 10

Leave a Comment

Press ESC to close