📦 Containers on FreeBSD: Podman, OCI Images and Jails

Run standard OCI container images on FreeBSD with Podman. See that a container is a jail, build an image, and even run a Linux container (Alpine) on the FreeBSD kernel

Video: Containers on FreeBSD (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.

Containers and jails

You have met the two halves of this page already. A jail (jails page) is FreeBSD's way to fence a set of processes into their own file system, hostname and network, on the same kernel. A container, in the Docker sense, is the same idea plus a packaging standard: an image (a stack of file-system layers and a recipe) that you can download from a registry and run anywhere that understands it. The standard is called OCI (Open Container Initiative), and it is the format behind Docker, Podman and Kubernetes.

FreeBSD can run OCI images with Podman (the daemonless container tool) and ocijail, a runtime that starts the container as a jail. So there is nothing new inside: a FreeBSD container is a jail, and you will see that on the host's own jls. What the OCI tooling adds is convenience: pull a ready-made image with one command, build your own from a recipe, and share it. The FreeBSD project publishes official images for this.

An honest caution: the package description of ocijail calls it an "Experimental, proof-of-concept OCI-compatible runtime for jails". It worked well for everything below, but it is younger than jails themselves, so test before you depend on it.

Everything on this page was run for real on FreeBSD 15.0-RELEASE in a virtual machine with 2 GB of memory. We downloaded about 153 MiB of packages and a few small images.

Step 1: install

⚠️ 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:~ # pkg install -y podman-suite 2>&1 | grep -E "Number|download|space|podman-suite|ocijail|Installing podman"
	ocijail: 0.6.1 [FreeBSD-ports]
	podman-suite: 20260414 [FreeBSD-ports]
Number of packages to be installed: 41
The process will require 615 MiB more space.
153 MiB to be downloaded.
[ 3/41] Fetching ocijail-0.6.1: .......... done
[16/41] Fetching podman-suite-20260414: . done
[23/41] Installing ocijail-0.6.1...
[23/41] Extracting ocijail-0.6.1: .... done
[39/41] Installing podman-5.8.6_4...
[41/41] Installing podman-suite-20260414...
[41/41] Extracting podman-suite-20260414: ... done
Message from podman-suite-20260414:
root@practice:~ # podman --version
podman version 5.8.6
root@practice:~ # which podman buildah skopeo ocijail conmon
/usr/local/bin/podman
/usr/local/bin/buildah
/usr/local/bin/skopeo
/usr/local/bin/ocijail
/usr/local/bin/conmon
root@practice:~ # pkg info -d podman | head -14
podman-5.8.6_4:
	conmon-2.2.1_10
	containernetworking-plugins-0.6_20
	containers-common-0.64.2_4
	gpgme-2.1.2
	ocijail-0.6.1
	gpgme-2.1.2 (libgpgme.so.45)

podman-suite is a "meta" package: 41 packages (153 MiB to download, 615 MiB installed) that bring Podman, Buildah (builds images), Skopeo (inspects and copies images between registries), ocijail (the runtime), conmon (watches the container), the standard container configuration files and the networking plug-ins. We checked the version after: Podman 5.8.4. The package's own notes say you can also add docker-registry (to host your own registry) and qemu-user-static (to build images for other CPUs): from the documentation, not run here.

root@practice:~ # zfs create -o mountpoint=/var/db/containers zroot/containers
root@practice:~ # zfs list -o name,mountpoint zroot/containers
NAME              MOUNTPOINT
zroot/containers  /var/db/containers
root@practice:~ # grep -n fdesc /etc/fstab || echo 'no fdescfs line yet'
no fdescfs line yet
root@practice:~ # echo 'fdesc /dev/fd fdescfs rw 0 0' >> /etc/fstab
root@practice:~ # mount -t fdescfs fdesc /dev/fd; mount | grep fdescfs
fdescfs on /compat/linux/dev/fd (fdescfs)
fdescfs on /dev/fd (fdescfs)
root@practice:~ # ls /usr/local/etc/containers
certs.d			containers.conf.sample	pf.conf.sample		policy.json.sample	registries.conf.sample	storage.conf
containers.conf		hooks.d			policy.json		registries.conf		registries.d		storage.conf.sample
root@practice:~ # grep -v '^#' /usr/local/etc/containers/containers.conf 2>/dev/null | grep . | head -5
[containers]
default_capabilities = [
  "CHOWN",
  "DAC_OVERRIDE",
  "FOWNER",
root@practice:~ # grep -E 'driver|graphroot|runroot' /usr/local/etc/containers/storage.conf | head -6
driver = "zfs"
runroot = "/var/run/containers/storage"
graphroot = "/var/db/containers/storage"
# If set, it must be different than graphroot.
# Storage options to be passed to underlying storage drivers
# and vfs drivers.

Step 2: find and pull an image

root@practice:~ # skopeo list-tags --override-os freebsd docker://docker.io/freebsd/freebsd-runtime 2>&1 | grep -E '"15|latest' | head -12
        "15.0",
        "15.0.beta2",
        "15.0.beta3",
        "15.0.beta4",
        "15.0.beta5",
        "15.0.rc1",
        "15.0.rc2",
        "15.0.rc3",
        "15.0.rc4",
        "15.1",
        "15.1.beta1",
        "15.1.beta2",
root@practice:~ # skopeo inspect --override-os freebsd docker://docker.io/freebsd/freebsd-runtime:15.0 2>&1 | grep -E 'Created|"Architecture"|"Os"|Layers' | head -8
    "Created": "2025-11-28T03:43:02Z",
    "Architecture": "amd64",
    "Os": "freebsd",
    "Layers": [

skopeo list-tags asks a registry which versions (tags) of an image exist. We added --override-os freebsd so that the tool asks for the FreeBSD variants of the images: registries hold images for many operating systems. The official image docker.io/freebsd/freebsd-runtime has tags for each FreeBSD release: 15.0, the 15.1 betas and so on. skopeo inspect shows the one we want: amd64, freebsd, created on 2025-11-28, and it has a single layer.

root@practice:~ # podman pull --os freebsd docker.io/freebsd/freebsd-runtime:15.0 2>&1 | tail -6
Trying to pull docker.io/freebsd/freebsd-runtime:15.0...
Getting image source signatures
Copying blob sha256:783aad9bdea10d40706a7f0fea23344c51ad5fb43846077da5a564b7fd494eee
Copying config sha256:d7fa7dafb151d968892c5ce9c8c951ab920e63c0e58434eb6a3d70da682bca75
Writing manifest to image destination
d7fa7dafb151d968892c5ce9c8c951ab920e63c0e58434eb6a3d70da682bca75
root@practice:~ # podman images
REPOSITORY                         TAG         IMAGE ID      CREATED        SIZE
docker.io/freebsd/freebsd-runtime  15.0        d7fa7dafb151  10 months ago  34 MB
root@practice:~ # zfs list -r -o name,used,refer zroot/containers | head -6
NAME                                                                                USED  REFER
zroot/containers                                                                   21.2M   284K
zroot/containers/9f51934f0c8fadf8d642fa5b96f4744b0b1614288a724c1e2839face8d61718e  20.9M  20.9M
root@practice:~ # podman pull --os freebsd docker.io/freebsd/freebsd-static:15.0 2>&1 | tail -5
Getting image source signatures
Copying blob sha256:017348f62c2bad853dd11d865e7e2e2a4a7d9e95b45ba84e700005b6b4e86ee8
Copying config sha256:1907d01c25d4680e5814a15100948441630ea0e73ee25897b2b9a4588f6322d2
Writing manifest to image destination
1907d01c25d4680e5814a15100948441630ea0e73ee25897b2b9a4588f6322d2
root@practice:~ # podman images --format "table {{.Repository}} {{.Tag}} {{.Size}}"
REPOSITORY                         TAG         SIZE
localhost/techcommune/hello        1           34 MB
docker.io/library/alpine           3.20        8.1 MB
docker.io/freebsd/freebsd-runtime  15.0        34 MB
docker.io/freebsd/freebsd-static   15.0        2.99 MB

Step 3: run your first container, and the network prerequisite

Podman's default is to give each container its own network on a virtual bridge, with address translation (NAT). Here is what happened:

root@practice:~ # podman network ls
NETWORK ID    NAME        DRIVER
2f259bab93aa  podman      bridge
root@practice:~ # kldstat | grep -c " pf.ko"
0
root@practice:~ # podman run --rm --os freebsd docker.io/freebsd/freebsd-runtime:15.0 uname -a 2>&1 | grep -v "level=warning"
Error: plugin type="bridge" failed (add): cni plugin bridge failed: The pf kernel module must be loaded to support ipMasq networks

podman network ls shows the default network, podman, a bridge. And the first run failed: The pf kernel module must be loaded to support ipMasq networks. The default container network on FreeBSD is built on the pf firewall (the module is not loaded here: kldstat finds 0). Our test machine uses ipfw (see the ipfw page), and the two do not mix well. To use the default bridge network you load pf and use rules like the sample file in /usr/local/etc/containers/ (from the documentation, not run here). For our tests we used host networking, where the container simply shares the host's network. It is the jail setting you know as ip4=inherit: --net host.

root@practice:~ # podman run --rm --net host --os freebsd docker.io/freebsd/freebsd-runtime:15.0 uname -a 2>&1 | tail -4
FreeBSD practice 15.0-RELEASE FreeBSD 15.0-RELEASE releng/15.0-n280995-7aedc8de6446 GENERIC amd64
root@practice:~ # podman run --rm --net host --os freebsd docker.io/freebsd/freebsd-runtime:15.0 /bin/sh -c "freebsd-version; hostname; id; ps aux; ls /" 2>&1 | tail -24
15.0-RELEASE
practice
uid=0(root) gid=0(wheel) groups=0(wheel),5(operator)
USER   PID %CPU %MEM   VSZ  RSS TT  STAT STARTED    TIME COMMAND
root 78222  0.0  0.1 14744 2548  -  SsJ  17:27   0:00.01 /bin/sh -c freebsd-version; hostname; id; ps aux; ls /
root 80412  0.0  0.1 14824 2792  -  RJ   17:27   0:00.00 ps aux
COPYRIGHT
bin
boot
dev
etc
lib
libexec
media
mnt
net
proc
rescue
root
sbin
tmp
usr
var
root@practice:~ # podman run --rm --net host --os freebsd docker.io/freebsd/freebsd-runtime:15.0 /bin/sh -c "cat /etc/os-release | head -3; which curl pkg bash fetch" 2>&1 | tail -6
/bin/sh: head: not found
cat: /etc/os-release: No such file or directory
/bin/sh: which: not found

Now it runs:

A container is a jail

Run one in the background and look at it from the host:

root@practice:~ # podman run -d --name box1 --net host --os freebsd docker.io/freebsd/freebsd-runtime:15.0 sleep 600
bd764d8e25ad03a0ab8979554c019b07a607087546e983ad7dc1264735d0df2e
root@practice:~ # podman ps
CONTAINER ID  IMAGE                                   COMMAND     CREATED                 STATUS       PORTS       NAMES
bd764d8e25ad  docker.io/freebsd/freebsd-runtime:15.0  sleep 600   Less than a second ago  Up 1 second              box1
root@practice:~ # jls
   JID  IP Address      Hostname                      Path
     1  10.0.2.50       web.example.org               /jails/web
     6                  practice                      /var/db/containers/storage/zfs/graph/324550efeae0040eee73ffff8bb60c0ed9fbe
root@practice:~ # ps -axo pid,jid,stat,command | grep "[s]leep 600"
  589   6 SCsJ sleep 600
root@practice:~ # podman exec box1 /bin/sh -c "hostname; ps aux | tail -3"
practice
/bin/sh: tail: not found
root@practice:~ # mkdir -p /root/shared && echo "written on the host" > /root/shared/note.txt
root@practice:~ # podman run --rm --net host --os freebsd -v /root/shared:/data docker.io/freebsd/freebsd-runtime:15.0 /bin/sh -c "cat /data/note.txt; echo written-in-container >> /data/note.txt"
written on the host
root@practice:~ # cat /root/shared/note.txt
written on the host
written-in-container

A volume shares a host folder with the container: -v /root/shared:/data. The container read the file the host had written, and appended a line, and the host sees both lines. (Volumes are done with a nullfs mount, as jails do: from the documentation, not run here.)

root@practice:~ # podman stop box1
box1
root@practice:~ # podman ps -a
CONTAINER ID  IMAGE                                   COMMAND     CREATED        STATUS                               PORTS       NAMES
bd764d8e25ad  docker.io/freebsd/freebsd-runtime:15.0  sleep 600   2 seconds ago  Exited (143) Less than a second ago              box1
root@practice:~ # jls
   JID  IP Address      Hostname                      Path
     1  10.0.2.50       web.example.org               /jails/web
root@practice:~ # podman rm box1 2>&1 | tail -1
box1
root@practice:~ # podman ps -a
CONTAINER ID  IMAGE       COMMAND     CREATED     STATUS      PORTS       NAMES

podman stop box1 stopped it (the exit code 143 is the normal "terminated" status of a stopped process). The jail is gone from jls: only the jails page's own jail is left. podman rm deleted the stopped container.

Build your own image

An image is built from a recipe, a Containerfile (Docker users know it as a Dockerfile). We wrote one, with a small script that it copies in:

root@practice:~ # cat > /root/hello/Containerfile <<'EOF'
> FROM docker.io/freebsd/freebsd-runtime:15.0
> RUN echo "Hello from a FreeBSD container" > /hello.txt
> COPY hello.sh /hello.sh
> CMD ["/bin/sh", "/hello.sh"]
> EOF
root@practice:~ # cat > /root/hello/hello.sh <<'EOF'
> #!/bin/sh
> cat /hello.txt
> echo "running on FreeBSD $(freebsd-version)"
> EOF
root@practice:~ # ls /root/hello
Containerfile	hello.sh
root@practice:~ # podman build --os freebsd --network host -t techcommune/hello:1 /root/hello 2>&1 | tail -14
STEP 1/4: FROM docker.io/freebsd/freebsd-runtime:15.0
STEP 2/4: RUN echo "Hello from a FreeBSD container" > /hello.txt
--> 60d2e074e549
STEP 3/4: COPY hello.sh /hello.sh
--> 951f4a6ed476
STEP 4/4: CMD ["/bin/sh", "/hello.sh"]
COMMIT techcommune/hello:1
--> 1c33bcbf9e34
Successfully tagged localhost/techcommune/hello:1
1c33bcbf9e341ce2278972837434765b9cfd09213554576fddce8b11555edadf
root@practice:~ # podman images
REPOSITORY                         TAG         IMAGE ID      CREATED                 SIZE
localhost/techcommune/hello        1           1c33bcbf9e34  Less than a second ago  34 MB
docker.io/freebsd/freebsd-runtime  15.0        d7fa7dafb151  10 months ago           34 MB
root@practice:~ # podman run --rm --net host techcommune/hello:1
Hello from a FreeBSD container
running on FreeBSD 15.0-RELEASE
root@practice:~ # podman history techcommune/hello:1 2>&1 | cut -c1-110
ID            CREATED                 CREATED BY                                     SIZE        COMMENT
951f4a6ed476  Less than a second ago  /bin/sh -c #(nop) CMD ["/bin/sh", "/hello....  0B          
<missing>     Less than a second ago  /bin/sh -c #(nop) COPY file:558da96a40f69a...  3.07kB      
60d2e074e549  2 seconds ago           /bin/sh -c echo "Hello from a FreeBSD cont...  3.07kB      FROM docker.i
d7fa7dafb151  10 months ago           make-oci-image.sh                              34MB        

A Linux container, on FreeBSD

Here is the surprise. Registries are full of Linux images. With the Linux compatibility layer from the previous page switched on, Podman can run those too, as jails, with the Linux programs translated by the layer. --os linux selects the Linux variant:

root@practice:~ # podman pull --os linux --arch amd64 docker.io/library/alpine:3.20 2>&1 | tail -4
Copying blob sha256:25f1d6b1951ac8eb3740558fe94cb83d377bdadf95fd9f98b50d2e1b96130471
Copying config sha256:bf8527eb54c3680e728d5b4b383a8ba730d72dae7236fbc8dff97ed6b224a731
Writing manifest to image destination
bf8527eb54c3680e728d5b4b383a8ba730d72dae7236fbc8dff97ed6b224a731
root@practice:~ # podman images
REPOSITORY                         TAG         IMAGE ID      CREATED         SIZE
localhost/techcommune/hello        1           1c33bcbf9e34  10 seconds ago  34 MB
docker.io/library/alpine           3.20        bf8527eb54c3  5 months ago    8.1 MB
docker.io/freebsd/freebsd-runtime  15.0        d7fa7dafb151  10 months ago   34 MB
root@practice:~ # podman run --rm --net host --os linux docker.io/library/alpine:3.20 /bin/sh -c "cat /etc/os-release | head -2; uname -sr; ls /" 2>&1 | tail -20
NAME="Alpine Linux"
ID=alpine
Linux 5.15.0
bin
dev
etc
home
lib
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
root@practice:~ # podman run -d --name lbox --net host --os linux docker.io/library/alpine:3.20 sleep 300
c8ad7f5d97c6bf800264d002736a9986cca0e2ac5e8a22b4c862a5ab7717c890
root@practice:~ # jls
   JID  IP Address      Hostname                      Path
     1  10.0.2.50       web.example.org               /jails/web
    11                  practice                      /var/db/containers/storage/zfs/graph/75d1ff51a9539fedbb483e1f5bf8f3d7a73fa
root@practice:~ # ps -axo pid,jid,stat,command | grep "[s]leep 300"
 4648  11  SsJ sleep 300 (busybox)
root@practice:~ # podman exec lbox /bin/sh -c "uname -sr; head -1 /etc/os-release"
Linux 5.15.0
NAME="Alpine Linux"
root@practice:~ # podman exec lbox /bin/sh -c "wget -q -O - http://techcommune.org 2>&1 | head -c 120; echo; echo wget-exit=$?"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width
wget-exit=0
root@practice:~ # podman stop lbox >/dev/null; podman rm lbox
lbox

Seen from the host it is the same as before: a jail (JID 11), with its sleep 300 process marked J (and (busybox), the Linux program that Alpine uses for everything). podman exec talks to the Linux system inside. And its wget fetched this very website (http://techcommune.org) and printed the first bytes of the page: a Linux program, in a Linux container, on a FreeBSD kernel, using the host's network.

The limits are the limits of the compatibility layer: there are no cgroups and no namespaces, so a Linux image that needs them, or needs a particular Linux kernel feature, will not run. Test the exact image you need. Simple, self-contained images such as Alpine's tools work nicely.

What Podman says about itself

root@practice:~ # podman info --format 'os={{.Host.OS}} arch={{.Host.Arch}} runtime={{.Host.OCIRuntime.Name}} storage={{.Store.GraphDriverName}} graphroot={{.Store.GraphRoot}}'
os=freebsd arch=amd64 runtime=ocijail storage=zfs graphroot=/var/db/containers/storage
root@practice:~ # podman system df
TYPE           TOTAL       ACTIVE      SIZE        RECLAIMABLE
Images         5           0           42.07MB     42.07MB (100%)
Containers     0           0           0B          0B (0%)
Local Volumes  0           0           0B          0B (0%)
root@practice:~ # zfs list -r -o name,used zroot/containers | wc -l
       6
root@practice:~ # zfs list -o name,used zroot/containers
NAME               USED
zroot/containers  27.2M

podman info confirms the setup: the host is os=freebsd, the runtime is ocijail, the storage is zfs, under /var/db/containers/storage. podman system df totals the images (5 entries, 42 MB), and zfs list shows the whole container store takes 27.2 MB on disk.

What we did not do

All from the documentation, not run here:

Containers, jails and virtual machines

Treat images like any software from the internet: know where they came from. Pull from official sources, pin the version (a tag, or better a digest), and scan what you run (the hardening page's pkg audit covers packages, not the contents of images).

⚠️ A container is not a security boundary you can forget about. It is a jail, and a good one, but a bug in the shared kernel is a bug for all of them. Keep the host patched, give containers only the volumes and network they need, and do not run untrusted images with --net host and a shared folder on a machine that matters.

Why did the container feel right at home on FreeBSD?<br>
It had been living in a jail<br>
since long before it was called a container.

🧪 Try it yourself

On a FreeBSD machine with ZFS (a virtual machine is fine):

1. Install podman-suite and make a ZFS dataset for the container store. Check that podman info says ocijail and zfs. 2. List the tags of the freebsd/freebsd-runtime image with skopeo. Pull the 15.0 tag with --os freebsd. 3. Run uname -a and ps aux in a container with --net host. How many processes does it see? 4. Start a container in the background and look at it with jls and ps -axo pid,jid,command. Stop it, and look again. 5. Share a folder with -v, write to it from the container, and read it on the host. 6. Write a Containerfile that adds a file and a command, build it, and run it. Look at podman history. 7. Pull alpine with --os linux --arch amd64 and run it. (You need the Linux compatibility layer.) What does uname -sr say? 8. Try a run without --net host. What does it ask for?

The cheat sheet

← Hardening FreeBSD: A Tested Baseline · ↑ FreeBSD Corner · FreeBSD in the Cloud: First-Boot Provisioning with nuageinit →