Crate is a containerizer for FreeBSD. It packages applications and services into isolated, self-contained containers (.crate files) and runs them in dedicated FreeBSD jails with automatic networking, firewall, and resource cleanup.
Crate containers contain everything needed to run the containerized software — only the crate executable and the FreeBSD kernel are required at runtime.
Language: C++17 | License: BSD 3-Clause | Status: Alpha (active development since 2019)
Українська версія | Comparison with BastilleBSD
- 34 commands:
create,run,validate,snapshot,list,info,console,clean,export,import,gui,stack,stats,logs,stop,restart,top,inter-dns,vpn,inspect,migrate,backup,backup-prune,restore,replicate,template,retune,throttle,doctor,vm-wrap,update,profile-from-trace,audit,wrap— all described incrate(5) - YAML specification with template inheritance (
--template) - ZFS integration: snapshots (create/list/restore/delete/diff), COW clones, encryption (AES-256-GCM/CCM, AES-128), dataset attachment
- Copy-on-Write: ephemeral or persistent modes via ZFS clone or unionfs
- Export/Import: running containers to
.cratearchives with SHA256 validation, directory traversal protection, OS version checking - Container listing in table or JSON format (
crate list -j) - Interactive console access (
crate console TARGET) - pkgbase support for FreeBSD 16+ (
--use-pkgbase)
- 5 X11 modes: shared (host Xorg), nested (Xephyr), headless (Xvfb), GPU (DRM leasing with nvidia/amdgpu/intel), none
- VNC: x11vnc with optional password, automatic port allocation
- noVNC/WebSocket: browser-based access via websockify
- GUI session manager (
crate gui): list, focus, attach, url, tile, screenshot, resize - Clipboard isolation: isolated/shared/none modes with directional control (in/out/both)
- D-Bus isolation: per-container system/session bus policy (allow_own, deny_send)
- OpenGL/GPU acceleration: hardware rendering with driver auto-detection
- Video devices: V4L
/dev/videoNpassthrough
- 4 network modes: NAT (default), bridge (
if_bridge), passthrough (direct NIC), netgraph (ng_bridge) - NAT mode: ipfw-based with automatic 10.0.0.0/8 addressing, outbound control (wan/lan/host/dns), inbound port forwarding (TCP/UDP)
- Bridge mode: container on physical network via
if_bridgewith DHCP or static IP - Passthrough mode: dedicated physical NIC assigned directly to container via VNET
- Netgraph mode:
ng_bridge+eifacealternative toif_bridge - DHCP: synchronous lease acquisition (
SYNCDHCPsemantics) - Static IP: CIDR notation (e.g.
192.168.1.50/24) with gateway - IPv6: NAT ULA (fd00:cra7:e::/48), SLAAC for bridge/passthrough/netgraph, static IPv6 addresses
- Static MAC: deterministic MAC address generation (SHA-256, vendor OUI
58:9c:fc) - VLAN: 802.1Q tagging (ID 1-4094) for bridge/passthrough/netgraph modes
- Multiple interfaces: primary + extra interfaces per container, each with independent mode/IP/VLAN
- Named networks: define reusable network profiles in system config, reference by name in container specs
- pf anchors: per-container firewall policy (block_ip, allow_tcp/udp, default_policy)
- DNS filtering: per-jail unbound with domain blocking (wildcard patterns, nxdomain/redirect)
- Dynamic firewall slots: unique rule numbers, no conflicts, RAII cleanup
- Non-root by default: the command runs as the operator's own account mirrored into the jail; a root operator gets the dedicated unprivileged
crateaccount;run/user: rootis the explicit opt-in,run/user: wwwpicks a package's account (1.1.64) - securelevel: configurable (-1 auto / 0-3, default=2)
- children.max: configurable (default=0 — no nested jails)
- CPU pinning: cpuset via
security:in YAML - RCTL resource limits: memoryuse, pcpu, maxproc, openfiles, and 20+ other resources
- MAC bsdextended: ugidfw rules for granular access control
- MAC portacl: port binding restrictions
- Capsicum: capability-based sandboxing
- pathnames.h: absolute paths to all external commands (CWE-426 protection)
- Environment sanitization: clean environ, restore only TERM/DISPLAY/LANG
- execv (no PATH search) instead of execvp in any privileged context
(crated runs as root;
crate(1)itself is unprivileged since 1.0.0) - lstat for symlink validation (CWE-59 protection)
- JAIL_OWN_DESC: race-free jail removal (FreeBSD 15+)
- Archive traversal protection: validates paths before extraction
- Signal-safe RAII cleanup: guaranteed resource cleanup on SIGINT/SIGTERM
- Device classes: the jail's
/devshows only basic nodes plus thedevices:classes the spec names (sound, gpu, video, input, usb, serial);terminal/devfs_rulesetremains the escape hatch (1.1.62)
- Managed services: auto-start/stop in order, rc.conf generation
- Socket proxying: socat-based host<->jail socket forwarding
- SysV IPC / POSIX mqueue: configurable per container
- Tor integration: built-in tor proxy option with optional control port
run:begin,run:after-create-jail,run:before-start-services,run:before-execute,run:after-execute,run:endcreate:begin,create:end
| Command | Description |
|---|---|
crate create -s spec.yml -o app.crate |
Create a container from specification |
crate create -s spec.yml --template base.yml |
Create with template inheritance |
crate run -f app.crate [-- args] |
Run a container |
crate run NAME [-- args] |
Run a profile by name: spec from ~/.config/crate/profiles/, /usr/local/share/crate/profiles/ or the installed examples, .crate built on first use (1.1.59) |
crate run --status-fd N ... |
Same, plus one JSON object per lifecycle event (jail_created, net_ready, exited, ...) on descriptor N (1.1.65) |
crate profile-from-trace -o spec.yml -- CMD [args] |
Run CMD once on the host under truss(1) and draft a minimal spec from what it touched (1.1.60) |
crate audit TARGET [-j] [--offline] |
What a running container can actually reach, seen from inside: host paths, devices, sockets, net (1.1.61) |
crate wrap install PROFILE [--desktop] |
A launcher in ~/.local/bin (and a .desktop entry) that starts the profile's container; wrap remove, wrap list (1.1.66) |
crate validate -s spec.yml |
Validate a specification |
crate snapshot create|list|restore|delete|diff |
Manage ZFS snapshots |
crate list [-j] |
List running containers (table or JSON) |
crate info TARGET |
Detailed container information |
crate console TARGET [-u USER] |
Interactive shell in a container |
crate clean [-n] |
Clean up orphaned resources (dry-run supported) |
crate export TARGET [-o FILE] [-P PASSFILE] [-K SIGNKEY] |
Export running container to .crate (optional encryption + ed25519 signature) |
crate import FILE [-o FILE] [--force] [-P PASSFILE] [-V PUBKEY] |
Import .crate with validation (auto-decrypts, verifies signature) |
crate gui list|focus|attach|url|tile|screenshot|resize |
GUI session manager |
crate top |
Live, htop-style resource monitor across all running containers (1 Hz; q to quit) |
# Create a container from a spec
crate create -s examples/firefox.yml -o firefox.crate
# Run it
crate run -f firefox.crate
# ...or skip both steps: the installed examples are profiles (1.1.59)
crate run firefox
# List running containers
crate list
# Open a shell in a running container
crate console firefox
# Export a running container
crate export firefox -o backup.crate.crate artefacts are portable — unlike Bastille's place-bound jails — and
since 0.5.4 they can also be private. Exports are wrapped in
AES-256-CBC + PBKDF2 via openssl enc(1) so the same image can travel
between hosts without leaking its filesystem to anyone with network or
backup-storage access.
# 1. Write a passphrase to a file (must be mode 0600 — owner-only)
printf 'cRatE2026craTE#UKR' > /etc/crate/secret
chmod 0600 /etc/crate/secret
# 2. Export, encrypted
crate export firefox -P /etc/crate/secret -o firefox.crate
# -> firefox.crate (AES-256-CBC ciphertext)
# -> firefox.crate.sha256 (over the encrypted bytes)
# 3. scp/rsync to the target host …
# 4. Import — encryption is auto-detected from magic bytes
crate import firefox.crate -P /etc/crate/secret
# Without -P on an encrypted archive, the import fails fast with a clear message.Notes:
- The example passphrase
cRatE2026craTE#UKRis illustrative — pick your own. PBKDF2 makes brute force expensive but a strong passphrase is still your job. - The passphrase file goes in via
-kfile <path>toopenssl, so the secret never appears on the command line and is not visible viaps. craterefuses any passphrase file that isn't0600, regular, and non-empty.- The
.sha256sidecar covers the ciphertext: verify it out-of-band before decrypting if you want detection of bit-flips on transport. - The
.sha256sidecar covers the ciphertext: verify it out-of-band before decrypting if you want detection of bit-flips on transport. - For authenticated provenance ("this archive really came from me"),
pair
-Pwith-K/-V(ed25519 signing — see below).
Encryption (the -P flag above) gives you confidentiality. Signing
gives you authenticity — a recipient can verify a .crate archive
really came from the holder of the matching secret key, even if it
travelled through untrusted intermediaries. The two are independent
and can be combined.
# 1. One-time: generate an ed25519 keypair
openssl genpkey -algorithm ED25519 -out crate-sign.key
openssl pkey -in crate-sign.key -pubout -out crate-sign.pub
chmod 0600 crate-sign.key
# (publish crate-sign.pub anywhere; keep crate-sign.key secret)
# 2. Sign on export. Combine with -P for confidentiality + authenticity.
crate export firefox -K crate-sign.key -P /etc/crate/secret -o firefox.crate
# -> firefox.crate (AES-256-CBC ciphertext)
# -> firefox.crate.sha256 (over the encrypted bytes)
# -> firefox.crate.sig (ed25519 signature over the encrypted bytes)
# 3. On the recipient: verify with the public key.
crate import firefox.crate -V crate-sign.pub -P /etc/crate/secret
# Refuses to import if firefox.crate.sig is missing/mismatched —
# unless --force.What gets signed: the on-disk archive bytes (including any encryption layer). So:
- A tampered ciphertext is detected by the signature, even before the recipient enters the passphrase.
- The public key can verify provenance without holding the passphrase.
- An unsigned archive imports normally; a signed archive without
-Vrefuses to import (intentionally — opt-in to the looser unsigned mode with--force).
Notes:
- Implementation:
openssl pkeyutl -sign -rawin -inkey ed25519-key. ed25519 produces a fixed 64-byte signature;.sigis binary. - Secret-key file rejected unless mode
0600, regular, non-empty. - The signature path is
<archive>.sig. Verifier exit codes ≠ 0 abort the import unless--force.
Every state-changing crate command (create, run, stop, restart,
snapshot, export, import, clean, console, gui, stack)
appends a one-line JSON record to /var/log/crate/audit.log. Read-only
commands (list, info, stats, logs, validate) are skipped to
keep the log lean.
$ crate create -s spec.yml -o myapp.crate
...
$ tail -1 /var/log/crate/audit.log | jq .
{
"ts": "2026-05-01T20:55:01Z",
"pid": 12345,
"uid": 1000, // real uid (the user)
"euid": 0, // effective uid (root / sudo / legacy crate.x)
"gid": 1000,
"egid": 0,
"user": "alice",
"host": "build-server",
"cmd": "create",
"target": "spec.yml",
"argv": "'crate' 'create' '-s' 'spec.yml' '-o' 'myapp.crate'",
"outcome": "ok"
}Notes:
- The file lives under
Config::Settings::logs(default/var/log/crate); change it viacrate.yml. - Mode 0640, append-only writes — fits the
auditd(8)/syslogd(8)model. Rotate withnewsyslog(8). - Captures both real and effective uid/gid so reviewers see
"user X (uid=1000) acted via euid=0" — relevant whenever crate runs with
elevated euid (sudo, or the legacy setuid
crate.xbuild). Since 1.0.0crate(1)is unprivileged and delegates privileged work tocrated(8), which keeps its own per-user audit. - Pair
outcome: "started"andoutcome: "ok"records bypidto measure command duration;failed: <msg>records contain the exception message for post-mortem.
Bridge mode used to require an operator to pre-create bridge0 (or
whichever interface the spec named) before crate run. Specs can
now opt into on-demand creation:
options:
net:
mode: bridge
bridge: bridge0
auto_create_bridge: true
ip: dhcpWhen the bridge already exists, auto_create_bridge is a no-op (so
multiple containers can share bridge0 without races). When the
bridge is missing and the flag is set, crate creates it (ifconfig bridge0 create up) and destroys it on container teardown only if
crate created it in this run. Without the flag, a missing bridge
produces a clear error pointing at the option.
The interface name is validated against [A-Za-z0-9_]{1,15} with a
required driver prefix and a required unit number — typos like
brige0 are caught before they reach ifconfig(8).
crate top polls all crate-managed jails once per second and renders
a table with per-container CPU%, memory, write rate (WRITE/s, RCTL
writebps — bytes written per second, not disk usage), and process
count. Press q (or Ctrl-C) to quit. CPU% is RCTL pcpu (the
kernel's own smoothed figure) and can exceed 100% on multi-core jails
— the build-ci row in the example below is using about three cores'
worth of CPU:
NAME JID IP CPU% MEM WRITE/s PROC
postgres 12 10.0.0.20 14.2 420.0 MB 12.5 MB/s 23
nginx-edge 15 10.0.0.21 3.1 48.0 MB 0 B/s 7
build-ci 18 10.0.0.22 210.5 1.2 GB 340.0 MB/s 42
3 jails CPU 227.8% MEM 1.7 GB WRITE 352.5 MB/s PROC 72
When stdout is not a terminal (crate top | grep, watch crate top),
the renderer emits one frame and exits. crate top is a read-only
command and does not appear in /var/log/crate/audit.log.
files: shares no longer require the host source and the jail-side
mount point to live on the same filesystem. When crate run detects
a cross-device path pair (host on tmpfs, jail dataset on a different
ZFS pool, external disk, etc.), it automatically falls back to a
single-file nullfs bind-mount instead of the hard-link path that
returns EXDEV. The semantics inside the jail are identical:
read/write, in-place edits, ownership and mode all reflect the
underlying host file.
# This now works regardless of whether /etc/resolv.conf and the
# jail dataset share a device. crate decides per-file whether to
# hard-link (same device) or nullfs-bind (cross device).
files:
/etc/resolv.conf: /etc/resolv.conf
/var/log/myapp.log: /home/me/logs/myapp.logThe strategy table is published in CHANGELOG.md (0.6.0); the pure
decision logic lives in lib/share_pure.{h,cpp} with a 9-case ATF
test covering every cell of the (host_exists × jail_exists ×
same_device) matrix.
crate supports five X11 display modes — they are not equivalent
from a security standpoint. crate validate and crate run both
emit warnings when an isolation-weak mode is selected.
| Mode | Isolation | Use when |
|---|---|---|
nested (Xephyr) |
Strong: containers get a private X server | desktop apps, default for production |
headless (Xvfb) + VNC/noVNC |
Strong: no host X access at all | server/automation/screenshot |
gpu (DRM leasing) |
Strong: dedicated GPU, no host X | accelerated workloads |
shared |
None — full read/write access to the host X server | only inside fully trusted jails |
none |
n/a | no GUI |
Why shared is dangerous. Mounting the host's /tmp/.X11-unix
into a jail means any process inside that jail can:
- Read every keystroke typed into any application on the host (X is global, not per-window).
- Move/raise/iconify/destroy any host window.
- Take screenshots of the entire desktop.
crate warns about this both at validate time (crate validate spec.yml)
and at run time. Operators who deliberately accept the risk can suppress
the runtime warning with CRATE_X11_SHARED_ACK=1.
# DON'T (default behaviour for legacy specs)
options: [x11] # ⚠️ implicit mode=shared
# DO
options: [x11]
x11:
mode: nested # private Xephyr server per jail
resolution: 1920x1080# spec.yml
pkg:
install: [firefox]
run:
command: /usr/local/bin/firefox
options: [net]
gui:
mode: headless
vnc: true
novnc: true
vnc_password: secretcrate create -s spec.yml -o firefox-headless.crate
crate run -f firefox-headless.crate &
crate gui url firefox # prints noVNC URL for browser accessUnlike gui.mode: wayland (which binds the host compositor's
socket so an in-jail client can connect to it), gui.mode: compositor
runs a full Wayland compositor — sway, weston, cage, labwc, … —
inside the jail. The compositor command is whatever you put in
gui.compositor. Two backends:
gui.backend |
What it does | Devices exposed | Safety |
|---|---|---|---|
headless (default) |
WLR_BACKEND=headless, renders offscreen, exposed over VNC via wayvnc |
/dev/dri/* only if a render node exists (GPU accel) |
Safe — no input devices, no host-display conflict |
drm |
WLR_BACKEND=drm, drives the physical GPU + input directly via seatd |
/dev/dri/* and /dev/input/* |
# Headless sway, viewable over VNC
pkg:
install: [sway, wayvnc]
options: [net]
gui:
mode: compositor
backend: headless # default; omit for the same effect
compositor: sway # or "cage firefox", "weston", "labwc"
vnc: true # start wayvnc on 5900 (override with vnc_port)
# vnc_bind: 0.0.0.0 # default 127.0.0.1 (loopback-only)wayvnc serves an unauthenticated VNC stream, so it binds to
127.0.0.1 by default — reach it over an SSH tunnel. To expose it on
the network, set gui.vnc_bind explicitly (e.g. 0.0.0.0 or a jail
address); crate prints a warning when you do, and you should restrict
access at the firewall. (Built-in wayvnc authentication is a planned
follow-up.)
The drm backend additionally requires seatd reachable on the host
(service seatd onestart) or running inside the jail. Because it
exposes /dev/input/*, it is opt-in and documented as a privilege
surface in docs/trust-model.md.
# NAT (default) — automatic IP, ipfw NAT, outbound control
options:
net:
outbound: [wan, dns]
inbound-tcp:
8080: 80
# Bridge — container on physical network via if_bridge
options:
net:
mode: bridge
bridge: bridge0
ip: dhcp
static-mac: true
# Bridge with static IP and VLAN
options:
net:
mode: bridge
bridge: bridge0
ip: 192.168.1.100/24
gateway: 192.168.1.1
vlan: 100
ip6: slaac
# Passthrough — dedicated NIC directly to container
options:
net:
mode: passthrough
interface: vtnet1
ip: dhcp
ip6: slaac
# Netgraph — ng_bridge alternative to if_bridge
options:
net:
mode: netgraph
interface: em0
ip: 192.168.1.100/24
gateway: 192.168.1.1
static-mac: true
# Multiple interfaces
options:
net:
mode: bridge
bridge: bridge0
ip: dhcp
extra:
- mode: bridge
bridge: bridge1
ip: 10.0.0.50/24
gateway: 10.0.0.1
vlan: 100
# Using named networks (defined in crate.yml)
options:
net:
network: external
ip: 192.168.1.50/24
extra:
- network: internal
ip: 10.0.0.50/24Configuration files (YAML, higher priority wins):
| File | Scope |
|---|---|
/usr/local/etc/crate.yml |
System-wide |
/usr/local/etc/crate.d/*.yml |
Drop-in fragments (alphabetical order) |
~/.config/crate/crate.yml |
Per-user |
Key options: prefix, cache, logs, zfs_enable, zfs_zpool, network_interface, default_bridge, static_mac_default, bootstrap_method, securelevel, children_max, search_path, compress_xz_options, networks.
Define reusable network profiles in the system config and reference them by name in container specs:
# /usr/local/etc/crate.yml
networks:
external:
mode: bridge
bridge: bridge0
gateway: 192.168.1.1
static-mac: true
internal:
mode: bridge
bridge: bridge1
gateway: 10.0.0.1
vlan: 100
dmz:
mode: netgraph
interface: em1
gateway: 172.16.0.1# container spec (.crate)
options:
net:
network: external
ip: 192.168.1.50/24
extra:
- network: internal
ip: 10.0.0.50/24Named network fields serve as defaults — explicit values in the container spec always override.
crate run NAME without -f (1.1.59, after firejail's <app>.profile + <app>.local) looks a spec up by name, first match wins:
| Path | Who writes it |
|---|---|
~/.config/crate/profiles/NAME.yml |
the operator |
/usr/local/share/crate/profiles/NAME.yml |
the site (directory created by the operator, root-owned) |
/usr/local/share/examples/crate/NAME.yml |
shipped with crate — the examples double as the profile set |
The .crate is built on first use into ~/.local/share/crate/profiles/NAME.crate and reused; it is rebuilt when the profile or its .local is newer. The jail is named after the profile, exactly as with -f NAME.crate. The build is a crate create, which still needs root: an unprivileged operator gets a message with the exact crate create -s … -o … && chown recipe for root (the merged spec is already written), and runs normally once the archive exists.
An optional ~/.config/crate/profiles/NAME.local.yml is merged on top of the profile: maps key by key, lists appended without duplicates, options by option name, scalars replaced. It may add packages, options, limits, run arguments and shares under $HOME:
# ~/.config/crate/profiles/firefox.local.yml
pkg:
install: [noto-emoji]
run:
command: /usr/local/bin/firefox --private-window
dirs:
share:
- [$HOME/Downloads, $HOME/Downloads]
limits:
memoryuse: 4GIt may not loosen what the profile author chose: security_advanced, dbus, base, scripts, encrypted, securelevel, socket_proxy and shares of host paths outside your home are refused with the reason. For those, copy the profile into ~/.config/crate/profiles/ and edit the copy. The merged spec is written next to the archive as NAME.yml (generated) and is what +CRATE.SPEC contains. Profile names follow the jail-name rule ([A-Za-z0-9._-]), so the chromium+tor-style examples need a copy under a dash name.
Since 1.1.59 crate create --template uses the same YAML merge and the merged spec goes into the archive; before, the template only affected the build and a --template privacy crate ran without tor, encryption, COW or the DNS filter.
crate wrap install PROFILE (1.1.66, after firejail's firecfg) puts the container in front of the program: it writes ~/.local/bin/PROFILE with exec crate run PROFILE -- "$@", so firefox typed in a shell runs the firefox profile when ~/.local/bin comes before /usr/local/bin in PATH (the command tells you when it does not). --desktop adds ~/.local/share/applications/crate-PROFILE.desktop (Exec=crate run PROFILE -- %U, Terminal=true unless the profile has x11/gui, Icon= the profile name or --icon) so menu entries and file associations start the container too; --no-launcher writes only that, --name picks another launcher name. Both files carry a marker naming the profile: wrap remove PROFILE and wrap list only touch files that carry it, and install refuses to overwrite a file without it unless --force. Everything lives in your home, no privilege is needed.
crate wrap install firefox --desktop # `firefox` and the menu entry start the container
crate wrap install fetch --name sfetch # ~/.local/bin/sfetch -> crate run fetch
crate wrap list
crate wrap remove firefoxcrate profile-from-trace -o app.yml -- <command> [args] (1.1.60, after firejail's --build) runs the program once on the host, unsandboxed, under truss -f, and turns the trace into a minimal spec draft:
| Seen in the trace | Becomes |
|---|---|
executed /usr/local programs |
pkg/install (owners from pkg which; packages only read from are a comment) |
| files under your home | dirs/share / files/share with $HOME; .config/<app>, .local/share/<app>, .mozilla-style state directories listed separately with a "remove to keep it per container" comment; .cache, .Xauthority and login dotfiles skipped |
| other directories | dirs/share, collapsed to the shortest ancestors; base system, /usr/local, /tmp, /var, /dev, /mnt, /media ignored |
X11 / D-Bus sockets, /dev/dri, /dev/video*, certificate files |
x11, dbus/session or dbus/system, gl, video, ssl-certs |
| outbound connections | options/net/outbound by address class (wan, lan, host, dns for port 53); the plain net option when all four are needed |
bind+listen, datagram binds |
inbound-tcp, inbound-udp |
Destinations seen, sound/input/USB devices, Wayland sockets, unknown unix sockets and unowned /usr/local files become comments at the end. --trace-file FILE uses a saved truss output instead of running anything; --keep-trace FILE keeps the raw trace. Review the draft, then crate validate -s app.yml and crate create.
crate audit <container|jid> (1.1.61, after firejail's --audit) answers the question the spec cannot: what does the running container actually reach? One /bin/sh probe runs inside the jail with base-system tools only (mount, ifconfig, netstat, sysctl, ls, find, sockstat, ps, nc, drill) and its output becomes the PASS/WARN/FAIL table crate doctor uses, or -j JSON:
| Category | Reported |
|---|---|
jail |
securelevel, hostname and kern.hostid compared with the host's, the allow.* flags as the jail sees them (raw sockets, mount, chflags, sysvipc, socket_af, enforce_statfs, devfs ruleset) |
mounts |
every host path visible through nullfs, rw or ro; procfs, zfs, tmpfs |
devfs |
device classes: gpu, video, sound, input, usb, serial, disks, raw (mem, kmem, bpf, pf) |
interfaces |
addresses and the default route |
sockets |
inet listeners; unix sockets on the filesystem that belong to the host (X11, system/session D-Bus, Wayland, sound server) |
processes |
who runs what inside |
connectivity |
the resolver, one TCP connect to the default gateway, one DNS lookup (--offline skips both, --dns NAME picks the name) |
WARN marks an exposure worth a look rather than an error: a whole host area mounted read-write, raw disk or memory devices, keyboard devices, a host socket, a hostid equal to the host's. Exit status is 0, 1 (some WARN) or 2 (the probe did not run). crate doctor looks at the host, crate audit at one container; it needs root or the crated privops socket, like crate console.
A jail shares the host's kern.hostuuid / kern.hostid unless it is created with its own, and crate passed the host's name as the jail hostname, so every jail identified the machine to anything that read them (crate audit has warned about it since 1.1.61). Since 1.1.67 (bwrap/firejail --machine-id, --hostname):
privacy:
hostid: random # host | random (a fresh UUID v4 every run) | a literal UUID (stable for the crate)
hostname: random # host | random (crate-<8 hex>) | a literal host nameThe jail is created with host.hostuuid set and host.hostid derived the way /etc/rc.d/hostid does it (the first 32 bits of md5 of the UUID text); the jail's /etc/hostid gets the UUID so rc(8) inside agrees with the kernel. crate validate warns when tor or i2pd is used without privacy/hostid; templates/privacy.yml and the tor / i2pd examples carry both keys. Under rootless the two parameters travel in create_jail's whitelisted parameter list.
Since 1.1.62 every jail gets a private devfs ruleset (firejail's --nosound / --novideo / --no3d / --noinput / --nou2f, inverted): everything in /dev is hidden, the basic nodes are unhidden (null, zero, random, urandom, crypto, tty, ptmx, pts/*, fd/*, stdin/stdout/stderr, fuse, zfs; bpf* with the net option), then the classes the spec names:
devices:
sound: true # /dev/dsp*, mixer*, sndstat, audio*, midi*
gpu: true # /dev/dri, dri/*, drm/*, nvidia* (implied by `gl` and gui gpu/auto/compositor)
video: true # /dev/video* (implied by the `video` option)
input: true # /dev/input/* — everything typed on the host is readable; implied by gui compositor's drm backend
usb: true # /dev/usb/*, usbctl, ugen*, uhid*, u2f*
serial: true # /dev/cua*, ttyu*, ttyU*devices: [sound, gpu] is the list form; devices: all keeps the host's whole /dev visible, which is what every jail without terminal/devfs_ruleset got before 1.1.62 (disks, memory, keyboards included). terminal/devfs_ruleset: N is still honoured: the operator's ruleset is included in place of the built-in base and the classes go on top. crate validate warns about all, input, usb and serial; crate audit shows the resulting view from inside. The desktop examples carry devices: {sound: true}.
Three more knobs from the bwrap/firejail toolbox (1.1.63):
dirs:
share:
- $HOME/Downloads # read-write, as before
- $HOME/Documents:ro # read-only (nullfs MNT_RDONLY)
- [$HOME/Music, /mnt/music, ro] # [jail path, host path, ro|rw]
tmpfs: # a fresh tmpfs at every start
- /tmp # root-owned, sticky 01777
- $HOME/.cache # inside the home: owned by the jail user, 0700
# or with sizes: tmpfs: {/tmp: 512m, $HOME/.cache: 2g}
etc:
keep: [localtime, hosts, ssl] # host /etc entries copied into the jail's /etc at createA file share is a hard link or a copy and cannot be read-only — share its directory with :ro. tmpfs mounts come after the shares (a tmpfs above a share hides it; crate validate warns) and are refused on /, /dev, /proc, /etc, /bin, /sbin, /lib, /libexec, /usr, /usr/local and /var/run. The jail's /etc is the pristine one from base.txz — etc/keep is the way to carry the host's timezone, hosts file or certificate directory along (firejail's --private-etc, inverted); resolv.conf is written by crate run when the spec has networking. Stack volumes with :ro now reach the mount as read-only too. Under rootless the tmpfs goes through the new mount_tmpfs privops verb, path-gated like mount_nullfs.
crate run --status-fd N (1.1.65; bwrap --json-status-fd) writes one JSON object per line to a descriptor the caller opened, one line per phase, so a script, a CI job or a supervisor gets exact phases instead of grepping logs:
crate run --status-fd 3 -f nginx.crate 3>nginx.status.jsonl{"event":"started","ts":1761300000,"pid":4242}
{"event":"jail_created","ts":1761300001,"jid":12,"name":"nginx_pid4242","path":"/var/run/crate/jail-nginx-1a2b"}
{"event":"net_ready","ts":1761300001,"mode":"bridge","ip":"10.0.0.7"}
{"event":"mounts_ready","ts":1761300001}
{"event":"services_started","ts":1761300002}
{"event":"healthy","ts":1761300004}
{"event":"exec_started","ts":1761300004,"user":"alice","command":"/run.sh"}
{"event":"exited","ts":1761303604,"code":0,"reason":"exited normally","timed_out":false}
{"event":"teardown_done","ts":1761303605}A run that fails before the command ran ends with failed (with the error text) and teardown_done. Values are flat strings, integers and booleans; ip appears when the address is known. crate stack up and crated start containers over such a pipe and read it until jail_created instead of polling jls for the jail to appear, so a member that fails to start says why.
What a stack adds to a spec: member — its template, the pool address, the /etc/hosts and resolv.conf scripts, the named volumes — is composed into the member's spec file before the build, so it is what the archived +CRATE.SPEC holds and what the member's crate run executes (until 1.1.68 the member ran its bare spec). A template given by name is found like crate create --template finds it. ${KEY} placeholders stay in the archive; crate run --var KEY=VALUE substitutes them when it reads the spec.
run/timeout: 15m (firejail --timeout; seconds, or s/m/h) starts the command under timeout(1): SIGTERM at the deadline, SIGKILL ten seconds later, exit code 124, "timed_out": true in the exited event, and the usual teardown.
The templates/ directory provides ready-made base configurations:
| Template | Description |
|---|---|
standard.yml |
NAT with WAN+DNS, shared Downloads directory |
development.yml |
NAT with full outbound access, shared home, SysV IPC |
minimal.yml |
Bare jail, no network, no X11 |
privacy.yml |
Tor proxy, ZFS encryption, ephemeral COW, DNS filtering |
network-isolated.yml |
No network, nested X11 (1280x720) |
bridge-dhcp.yml |
Bridge mode with DHCP and static MAC |
bridge-static.yml |
Bridge mode with static IP and gateway |
passthrough.yml |
Passthrough with DHCP and IPv6 SLAAC |
netgraph.yml |
Netgraph mode with DHCP and static MAC |
The examples/ directory contains ready-to-use specifications:
Desktop applications: firefox, chromium, gimp, thunderbird, meld, mpv, vlc, qbittorrent, qtox, telegram-desktop, xfce-desktop, lxqt-desktop, lxqt-desktop-nested, lxqt-minimal, lxqt-wayland
Headless/GPU modes: firefox-headless, firefox-gpu, chromium-headless, chromium-gpu, gimp-headless, blender-gpu, glxgears-gpu
Network services: gogs, nginx, syncthing, tor, i2pd
Privacy (Tor/I2P): chromium+tor, firefox+i2pd, chromium+i2pd, qbittorrent+i2pd
Utilities: nmap, amass, wget, aria2, fetch, gzip, yt-dlp, xeyes
Thin jail (base/mode: host-ro, 1.1.58):
fetch-thin — the same fetch spec with the host's userland mounted read-only instead of a copied base system. The archive holds only /etc /var /root /tmp /home /usr/local; crate run nullfs-mounts /bin /sbin /lib /libexec /rescue /usr/{bin,sbin,lib,lib32,libexec,libdata,share,include} from the host before the jail starts. A few MB instead of hundreds, no big extraction at start, and a host upgrade upgrades every thin jail at its next start. /usr/local stays per container, so pkg/install and crate update --pkg-only work unchanged. The directory list and the host release are frozen in +CRATE.THIN inside the archive; starting on another release branch prints a warning. crate export of a running thin jail archives the writable layer only and yields a valid thin crate.
Top-level keys supported in .crate / .yml spec files:
| Key | Description |
|---|---|
base |
mode (full or host-ro — thin jail on nullfs, see below), host-dirs, keep, keep-wildcard, remove — base system layout |
pkg |
install, local-override, nuke — package management |
run |
command, service, user, timeout — what to execute in the container, as whom (1.1.64: the operator's mirrored account by default, crate when the operator is root, root on explicit request, or a jail account) and for how long at most (1.1.65: 90, 15m, 2h → timeout(1), exit 124) |
dirs |
share — shared directories (host<->jail) |
files |
share — shared files (host<->jail) |
options |
net, x11, ssl-certs, tor, video, gl, no-rm-static-libs, dbg-ktrace |
scripts |
Lifecycle hooks (run:begin, run:after-create-jail, etc.) |
security |
securelevel, children_max, cpuset, enforce_statfs, allow_* |
limits |
RCTL resource limits (memoryuse, pcpu, maxproc, etc.) |
ipc |
sysvipc, mqueue, raw_sockets |
zfs |
datasets — additional ZFS datasets to attach |
encrypted |
ZFS encryption (method, keyformat, cipher) |
cow |
Copy-on-Write (mode: ephemeral/persistent, backend: zfs/unionfs) |
dns_filter |
DNS filtering (allow, block, redirect_blocked) |
firewall |
Per-container pf policy (block_ip, allow_tcp/udp, default) |
x11 |
X11 display (mode: nested/shared/none, resolution, clipboard) |
gui |
GUI session (mode: nested/headless/gpu/auto/wayland/compositor, backend, compositor, vnc, novnc, resolution) |
clipboard |
Clipboard isolation (mode: isolated/shared/none, direction) |
privacy |
hostid: host|random|<uuid> — the jail's own kern.hostuuid/kern.hostid and /etc/hostid; hostname: host|random|<name> (1.1.67) |
dbus |
D-Bus policy (system, session, policy/allow_own/deny_send) |
services |
Managed services (managed list, auto_start) |
socket_proxy |
Socket forwarding (share, proxy with host/jail/direction) |
terminal |
Terminal isolation (devfs_ruleset, allow_raw_tty) |
devices |
Device classes visible in /dev: sound, gpu, video, input, usb, serial, or all (1.1.62) |
tmpfs |
Fresh tmpfs at these jail paths every start: [/tmp, $HOME/.cache] or {/tmp: 512m} (1.1.63) |
etc |
keep: [localtime, hosts, ssl] — host /etc entries copied into the jail's pristine /etc at create (1.1.63) |
security_advanced |
Capsicum, MAC bsdextended/portacl, hide_other_jails |
cd /usr/ports/sysutils/crate && make install cleanThe port supports build OPTIONS (enabled by default unless noted):
| Option | Description |
|---|---|
DAEMON |
Container lifecycle daemon (crated) |
SNMPD |
SNMP monitoring agent (crate-snmpd) |
EXAMPLES |
Example container specifications |
COMPLETIONS |
Bash/ZSH shell completions (off by default) |
ZFS |
Native ZFS API (libzfs) |
IFCONFIG |
Native interface configuration (libifconfig) |
PFCTL |
Native PF firewall control (libpfctl) |
CAPSICUM |
Capsicum sandbox support (libcasper) |
LIBVIRT |
Virtual machine support (bhyve via libvirt) |
X11 |
X11 display forwarding |
VNCSERVER |
Embedded VNC server (libvncserver) (off by default) |
LIBSEAT |
DRM/GPU session management (libseat) |
# Configure options interactively
cd /usr/ports/sysutils/crate && make config && make install cleanmake && sudo make installDependencies: yaml-cpp, rang (header-only), libjail / libnv / libnetgraph (base).
crated additionally needs cpp-httplib (header-only); crate-hub needs
cpp-httplib and sqlite3. The release number lives in the VERSION file:
the build generates lib/version.h from it, and make check-version
confirms port/Makefile and CHANGELOG.md agree with it.
Compile-time feature flags (set =0 to disable):
make HAVE_LIBZFS=1 HAVE_LIBIFCONFIG=1 HAVE_LIBPFCTL=1 HAVE_CAPSICUM=1 \
WITH_LIBVIRT=1 WITH_X11=1 WITH_LIBSEAT=1 WITH_LIBVNCSERVER=0All native API wrappers fall back to shell commands when compiled without the corresponding flag.
| Target | Description |
|---|---|
make |
Build crate CLI |
make all-daemon |
Build crate + crated daemon |
make all-snmpd |
Build crate + crate-snmpd agent |
make all-hub |
Build crate + crate-hub aggregator |
make install |
Install crate CLI + man pages |
make install-daemon |
Install crated, RC script, config |
make install-snmpd |
Install crate-snmpd + MIB file |
make install-hub |
Install crate-hub, its web dashboard and man page |
make check-version |
Verify VERSION, port/Makefile and CHANGELOG.md agree |
make install-examples |
Install example specs to /usr/local/share/examples/crate/ |
make install-completions |
Install shell completions |
make test |
Compile unit tests and run via kyua |
crated is a container lifecycle daemon providing a REST API for remote management.
- Listens on Unix socket (local) and TCP/TLS (remote)
- Token-based authentication (SHA-256 hashes, roles:
viewer/admin) - Rate limiting (100 req/s read, 10 req/s write)
- Prometheus metrics at
/metrics - FreeBSD RC service integration
| Endpoint | Auth | Description |
|---|---|---|
GET /healthz |
— | Health check |
GET /api/v1/containers |
— | List running containers |
GET /api/v1/containers/:name/gui |
— | GUI session info (display, VNC/WS ports) |
GET /api/v1/containers/:name/stats |
yes | Resource usage (RCTL) |
GET /api/v1/containers/:name/logs |
yes | Logs (?follow=true, ?tail=N) |
POST /api/v1/containers/:name/start |
yes | Start container from .crate |
POST /api/v1/containers/:name/stop |
yes | Stop container (SIGTERM → SIGKILL) |
POST /api/v1/containers/:name/restart |
yes | Stop + start in one call |
DELETE /api/v1/containers/:name |
yes | Destroy container |
GET /api/v1/containers/:name/snapshots |
yes | List ZFS snapshots |
POST /api/v1/containers/:name/snapshots |
yes | Create snapshot (body: {"name":"<snap>"} optional) |
DELETE /api/v1/containers/:name/snapshots/:snap |
yes | Delete a snapshot |
GET /api/v1/containers/:name/stats/stream |
yes | Server-Sent Events stream of RCTL counters (1 Hz) |
GET /api/v1/containers/:name/console (WS) |
yes | RFC 6455 WebSocket — interactive /bin/sh over a PTY (separate listener; admin role) |
POST /api/v1/containers/:name/export |
yes | Export running container to /var/run/crate/<name>-<unixtime>.crate; returns {file,size,sha256} |
GET /api/v1/exports/:filename |
yes | Stream a previously-exported .crate back to the client |
POST /api/v1/imports/:name |
yes | Upload a raw .crate body (octet-stream); atomic-rename into /var/run/crate/<name>.crate |
GET /api/v1/host |
— | Host system info |
GET /metrics |
— | Prometheus metrics |
The interactive console listens on its own TCP socket (console_ws.port
in crated.conf; default 0 = disabled), separate from the HTTP API.
It speaks RFC 6455 with version 13. The handshake requires
Authorization: Bearer <admin-token>; afterwards the daemon proxies
bytes between the WebSocket and a /bin/sh -i running inside the
target jail under jexec(8). Server→client traffic uses binary
frames so raw TTY escape sequences pass through; client→server
frames (text or binary) are written to the shell's stdin. PING/PONG
round-trip; CLOSE terminates the shell with SIGTERM.
The bind address goes through getaddrinfo(3) so it accepts
127.0.0.1, ::1, :: (dual-stack), or a hostname:
console_ws:
port: 9802
bind: "::" # dual-stack, or 127.0.0.1 / ::1 for loopback only/stats/stream emits one data: frame per second containing
{name, jid, ip, ts, <rctl counters...>}. Clients can consume it with
plain JavaScript via new EventSource(...) or with curl --no-buffer.
The stream self-terminates with event: end when the jail exits.
Snapshot names are validated server-side against [A-Za-z0-9._-]{1,64}
(. and .. rejected). POST /snapshots with no body generates an
auto name like auto_2026-05-01_220000. DELETE /snapshots/:snap is
idempotent — repeating it returns 500 with the underlying ZFS error,
since zfs destroy itself is not idempotent.
# /usr/local/etc/crated.conf
listen:
unix: /var/run/crate/crated.sock
tcp_port: 9800
tcp_bind: 0.0.0.0
tls:
cert: /usr/local/etc/crate/tls/server.pem
key: /usr/local/etc/crate/tls/server.key
ca: /usr/local/etc/crate/tls/ca.pem
require_client_cert: true
auth:
tokens:
- name: ansible
token_hash: "sha256:..."
role: admin
- name: grafana
token_hash: "sha256:..."
role: viewer
log:
file: /var/log/crated.log
level: info# /etc/rc.conf
crated_enable="YES"
# Control
service crated start
service crated stop
service crated statuscrate-snmpd is an AgentX subagent that exposes crate counters via SNMP under .1.3.6.1.4.1.59999. Today it serves four scalars — container count, running count, crate version and hostname; the per-container table that CRATE-MIB.txt (installed to /usr/local/share/snmp/mibs/) also defines is not implemented yet (use crated's /metrics for per-container figures). See crate-snmpd(8).
make all-snmpd && sudo make install-snmpdThe project uses Kyua and ATF (FreeBSD's standard test framework).
make test # Build and run all tests
cd tests && kyua test # Run from tests directory
kyua test unit/ # Unit tests only
kyua test functional/ # Functional tests only
kyua report -v # Verbose reportUnit tests (C++17, libatf-c++): spec parsing, network options, IPv6, jail lifecycle, error handling.
Functional tests (shell-based ATF): CLI commands (help, version, validate, list).
Supported: FreeBSD 13.0 and later (13.x, 14.x, 15.x). All version-specific features have safe fallbacks.
FreeBSD 15.0 adaptations (automatic, no user action needed):
- JAIL_OWN_DESC — race-free jail removal via owning descriptor (falls back to
jail_remove()on older kernels) - epair checksum offload fix —
txcsum/txcsum6disabled unconditionally (harmless on 13.x/14.x) - OS version mismatch warning — detects host vs container FreeBSD major version difference
- ipfw compatibility warning — alerts when FreeBSD 15 host runs a container built with older base
- Inbound network ports can't be accessed from the host running the crate because of this bug, currently they can only be accessed from other hosts.
crate is in its alpha stage. Active development since June 2019.
See the TODO file in the project.