jailmachine (jm) is docker-machine / podman machine for FreeBSD. One
command brings up a FreeBSD 15.1 virtual machine on your Mac, provisions it
with podman and bastille, and hands your host's podman or docker
client an endpoint pointing at it — so you can run native FreeBSD OCI
images, Linux images through the Linuxulator, and jails, from a
macOS terminal, without keeping a FreeBSD box around.
Status: working demo, v0.3.x. It is usable for real work, but it is not a Docker Desktop replacement: see what works, and what does not yet.
macOS on Apple Silicon only. qemu (for HVF) and podman (which also ships
gvproxy) are required; the cask declares both and creates the jpodman and
jdocker symlinks, otherwise they are yours to arrange.
brew install --cask gabrielbelli/tap/jailmachine
# or:
brew install qemu podman
go install github.com/gabrielbelli/jailmachine/cmd/jm@latest
ln -sf "$(go env GOPATH)/bin/jm" "$(go env GOPATH)/bin/jpodman"
ln -sf "$(go env GOPATH)/bin/jm" "$(go env GOPATH)/bin/jdocker"
# or, from source (PREFIX defaults to /opt/homebrew):
git clone https://github.com/gabrielbelli/jailmachine && cd jailmachine && make installjm doctor checks every tool and machine and prints a fix per failure.
Details in docs/INSTALL.md.
jm init # SSH key, download and verify the guest image, grow the disk, write the seed
jm start # boot, provision, connect podman, share host paths, start the forwarder and resolverjm init takes 60–115 s. It downloads roughly 800 MiB and checks its
SHA256, but the download is the smaller half — about 31 s on this link,
against 59–113 s for an init from an image already cached on disk.
Decompressing the 64 GiB disk image is what dominates: see
the machine itself. On the prebaked
image a cold first boot takes about 22 s (32 s was observed once with two
other VMs already running on the host) and a warm start 12–25 s on an idle
Mac; --image official:<release> provisions a stock FreeBSD cloud image on
first boot instead, taking about 2 minutes.
jpodman is podman pointed at the machine and jdocker is the docker CLI
pointed at the same engine, whatever your default connection or docker context
is; jm never repoints a default you already had (jm start --set-default opts
in, and on a Mac with no podman connections at all, podman itself promotes the
first one jm registers). Both wrappers start a stopped machine for you,
printing one line on stderr while it boots — JM_AUTOSTART=0, or
--no-autostart as the first argument, makes them fail instead. A machine
left idle for 30 minutes is suspended to disk and gives its memory back
to macOS; the wrappers wake it again in seconds, whatever JM_AUTOSTART
says.
The guest is FreeBSD, so Linux images need --os=linux with podman:
jpodman run --rm --os=linux docker.io/alpine echo hi # Linux, via the Linuxulator
jpodman run --rm ghcr.io/freebsd/freebsd-runtime:15.1 uname -srm # native FreeBSDjdocker needs no flag — the docker CLI has none, so the wrapper defaults
DOCKER_DEFAULT_PLATFORM=linux/arm64 and a plain jdocker run pulls the
Linux image as Docker Desktop would. Set DOCKER_DEFAULT_PLATFORM yourself,
or pass --platform, for native FreeBSD images.
jdocker run --rm docker.io/alpine echo hi
jdocker compose up -d # a compose.yaml, driving the guest's podman
jpodman kube play pod.yaml # or Kubernetes YAML, FreeBSD's native routeThose two and jpodman compose are all covered in
Compose and Kubernetes YAML.
Build a native FreeBSD image:
mkdir -p ~/jm-demo && cd ~/jm-demo
cat > Containerfile <<'EOF'
FROM ghcr.io/freebsd/freebsd-runtime:15.1
RUN env ASSUME_ALWAYS_YES=yes pkg bootstrap -f && pkg install -y curl && pkg clean -ay
CMD ["uname", "-srm"]
EOF
jpodman build -t jm-demo . && jpodman run --rm jm-demoPublish a port and reach it from the Mac (the forwarder reconciles a second or two after the container starts, hence the retry):
jpodman run -d --rm --os=linux -p 8080:80 --name web docker.io/busybox \
sh -c 'echo hello from the FreeBSD VM > /tmp/index.html && httpd -f -p 80 -h /tmp'
curl --retry 10 --retry-connrefused http://localhost:8080/ # hello from the FreeBSD VM
jm ports # what is mapped, where it binds, and why something is not
jpodman rm -f webFor plain podman or a docker client you would rather point yourself:
eval "$(jm env)" (fish: eval (jm env --shell fish)). A Linux image in a
compose file or a Pod manifest needs its platform naming — see
Compose and Kubernetes YAML.
Host directories appear in the guest at the same absolute path, so a
volume argument written on the Mac resolves inside the VM unchanged — from
any directory, with no rewriting and no /host_mnt prefix:
jpodman run --rm --os=linux -v ~/code:/app docker.io/alpine ls /app
jpodman run --rm --os=linux -v ~/code:"$HOME/code" docker.io/alpine ls "$HOME/code"A new machine shares your home directory, /Volumes, /private/tmp and the
per-user temporary directory $TMPDIR lives in (/var/folders/<hash>), so
-v $(mktemp -d):/work and -v $TMPDIR/x:/work both work. jm inspect
lists the set, and jm doctor checks that a file written on the host really
is visible to a container at the same path.
jm init --mount /work --mount /srv/data:ro # on top of the defaults
jm init --no-mounts # share nothing
# the share set changes only on a stopped machine, and applies on the next start
jm stop && jm set --mount /work --unmount /Volumes && jm start
jm stop && jm set --no-mounts && jm start # drop every share
jm inspect | grep -i shareEvery container can read and write everything shared — by default that is your whole home directory, including
~/.ssh,~/.awsand~/.jailmachineitself. That is the same posture as Docker Desktop's default, and it is a deliberate one. If you run images you do not trust, narrow it on a stopped machine:jm stop jm set --no-mounts # drop every share jm set --mount ~/code --mount "/srv/data:ro" # add back only these jm start
--no-mountscannot be combined with--mountor--unmountin one call, hence the two commands.
/tmpis the one path that cannot follow the rule. On macOS it is a symlink to/private/tmp, and a share mounted at the guest's own/tmpwould shadow it — so jm shares/private/tmpinstead and never rewrites your-vargument. Write-v /private/tmp/x:/app, not-v /tmp/x:/app; the latter fails withsource path does not exist— or, if that path happens to exist in the guest as well, silently binds the guest's own empty/tmp/x.
zsh users: brace a
:rosuffix — quoting does not help. In zsh,:roat the end of a word carrying a parameter expansion is a history modifier::rstrips the extension and theois left behind, so both$P:roand"$P:ro"become/Users/you/codeo. Only braces or a backslash survive:jm set --mount "${P}:ro" # or $P\:ro jpodman run --rm --os=linux -v "${P}:${P}:ro" docker.io/alpine ls "$P"Nothing warns you.
jm set --mount $P:roon a stopped machine exits0and changes nothing: the mangled path falls inside a root that is already shared, so it is absorbed and the:rois lost. The only signal is what is missing — no==> share:lines and no "attached on the next start" notice, just the machine summary. A correct"${P}:ro"prints both. bash is unaffected in every form.
Shares are for source trees and data.
utimesis a silent no-op, aninotifywatch cannot be created on a share at all —inotify_add_watchfails withBad file descriptor, where it works on an engine-managed volume and on the container's own filesystem, so use a polling watcher (#4) — and 9p is far slower than the guest's ZFS (~70 MB/s, and worse on metadata: 1000 small files in 3.6 s against 0.76 s) — keep build output in an engine-managed volume.
A file a container creates shows up on the Mac as
0600, with its real mode and owner inuser.virtfs.*xattrs. Shares use the 9pmapped-xattrsecurity model, which keeps guest ownership and modes in xattrs instead of on the host file — the only way a container running as root can rewrite a file it has just made read-only, whichgit cloneneeds and macOS refuses when the modes are host-native.JM_9P_SECURITY=nonetrades it back. See Modes and ownership on a share.
Whatever resolves on the Mac resolves in the guest and in containers, with
the same answer. jm start runs a small resolver on the host and points the
guest at it, so queries go through macOS's own resolution API: split-horizon
VPN resolvers, per-domain nameservers, search domains from scutil --dns,
/etc/hosts entries, .local mDNS names, and the special-use development
TLDs .test, .invalid, .home.arpa and .onion that a stock DNS resolver
would answer NXDOMAIN on its own. Joining or leaving a VPN converges
without a restart.
The Mac itself is host.docker.internal and host.containers.internal from
inside a container, and answers to its own hostname and .local name; a host
answer of 127.0.0.1 is rewritten to the address that means "the host" in
the guest, so a service on your loopback is reachable.
jpodman run --rm --os=linux docker.io/alpine ping -c1 host.docker.internal
jm doctor # asserts the guest resolves a host-only name to the right addressIf the guest's resolver cannot be brought up,
jm startwarns and carries on with the resolution the guest already had rather than failing; the check injm doctoris what reports the loss, andresolver.login the machine directory says why.
jpodman run -p works as on any other machine: a forwarder started by
jm start watches podman events and converges gvproxy's mapping table onto
the guest's container state.
Published ports bind every host interface by default, as
docker run -pdoes on Linux:127.0.0.1,::1,localhostand your LAN address, so anyone on your network can reach the container. Confine them to the loopback withjm init --publish-addr 127.0.0.1or, on an existing machine,jm set --publish-addr 127.0.0.1(it applies from the nextjm stop+jm start). The address is stored on the machine and shown byjm inspectandjm ports;JM_PUBLISH_ADDRis an override read atjm starttime and written onto the record.
Naming a host address in the flag itself works and wins over the default:
-p 127.0.0.1:8080:80 binds that address on the Mac and nothing else,
whatever --publish-addr says.
QEMU (-M virt,accel=hvf, EDK2 firmware, virtio) boots a FreeBSD 15.1 arm64
guest; networking is gvproxy,
the same userspace stack podman machine uses. Host to guest is SSH only, as
root with a dedicated ed25519 key: FreeBSD has no vsock driver, so the engine
socket is tunnelled over SSH rather than exposed directly. Host directories
are exported over virtio-9p (there is no virtiofs driver either) and mounted
declaratively by the guest before the engine starts.
flowchart LR
subgraph mac["macOS host"]
jm["jm (lifecycle)"]
cli["jpodman / jdocker / podman / docker"]
gv["gvproxy"]
fwd["port forwarder"]
res["host resolver"]
end
subgraph vm["FreeBSD guest (QEMU + HVF)"]
sshd["sshd"]
api["podman system service<br/>/var/run/podman/podman.sock"]
unb["local_unbound"]
work["containers + bastille jails"]
shr["9p shares at identity paths"]
end
jm -->|SSH control channel| sshd
cli -->|unix socket| gv
gv -->|ssh -L| api
fwd -->|events + ps| api
fwd -->|expose / unexpose| gv
gv -.->|virtio-net 192.168.127.2| sshd
unb -->|forward to 192.168.127.254| res
api --> work
jm -.->|virtio-9p| shr
work --> shr
work --> unb
Published ports are reconciled, not requested: the forwarder watches the
guest's container state and converges gvproxy's mapping table onto it. State
lives in ~/.jailmachine/machines/<name>/ (JM_HOME or --state-root move
it), so deleting it uninstalls completely. More in
docs/ARCHITECTURE.md.
[name] defaults to jailmachine. Exit codes: 0 ok, 1 failure, 2 usage.
Every flag and environment variable is in docs/USAGE.md.
| Command | Does |
|---|---|
jm init [name] |
Create a machine: SSH key, image download and SHA256 check, grow disk, NoCloud seed. --cpus, --memory (2048 MiB by default), --arc (the guest's ZFS cache cap, 512 MiB by default), --idle-suspend (30 min by default, 0 never), --disk, --image, --ssh-port, --mount, --no-mounts, --publish-addr |
jm start [name] |
Boot, provision on first boot, connect podman, mount the shares, start the port forwarder, the host resolver and the sleeper; wakes a suspended machine instead of booting it; idempotent |
jm stop [name] |
Stop the sleeper, forwarder and resolver, ask the guest to power off, then the hypervisor and the network provider. A suspended machine is restored first; --force discards its saved state |
jm suspend [name] |
Save a running machine to disk now and give its memory back; the first use wakes it. Refuses while containers, jails, engine clients or sessions are active unless --force, and always while a shared directory is in use |
jm ssh [name] [-- cmd] |
Root shell, or a command, in the guest |
jm podman / jpodman |
Run the host podman against the machine, whatever your default connection is; starts it if stopped, wakes it if suspended |
jm docker / jdocker |
Run the host docker CLI (and compose) against the machine's engine, leaving your docker contexts alone; starts it if stopped, wakes it if suspended |
jm env [name] |
Shell exports (CONTAINER_HOST, DOCKER_HOST) for podman and docker clients |
jm ports [name] |
Published container ports, where they bind, and the error per mapping |
jm list / jm inspect |
Machines and their computed state (running, stopped, suspended or broken), shares, publish address and what keeps an idle machine awake (--json on both) |
jm set [name] |
Change --cpus, --memory, --ssh-port, --disk (grows only, live if running), --mount/--unmount/--no-mounts, --publish-addr, --arc (live if running), --idle-suspend (any state, applies at once) |
jm console [name] |
Guest serial console log (-f to follow) |
jm rm [name] |
Remove the machine, its directory and its podman connections |
jm doctor |
Check qemu, HVF, EDK2 firmware, gvproxy, podman, ssh, the state root, share parity, resolver parity and every machine |
jm version / jm image build |
Build identity (--json); and, for maintainers, sealing a prebaked guest image (about 800 MiB as a .zst) |
jm init --image <source>. The contract an image must satisfy is in
docs/guest-contract.md and
ADR 0003;
JM_IMAGE_BASEURL points the prebaked source elsewhere, to test an
unpublished image.
| Source | What you get | Verification |
|---|---|---|
prebaked (default), prebaked:<ver> |
Already-provisioned guest from this repo's guest-<ver> GitHub release; first boot is a boot, nothing more |
Mandatory .sha256 sidecar, checked by jm init |
official, official:<release> |
Stock FreeBSD BASIC-CLOUDINIT-zfs cloud image, provisioned on first boot |
Mandatory CHECKSUM.SHA256 |
Path or http(s) URL to .raw (or .img), .raw.xz, .raw.zst |
Your own disk satisfying the contract; jm applies the seed, you own provisioning |
Sibling .sha256 if present, else image_trusted=false in jm inspect |
bastille is installed and configured in the guest (ZFS, bastille0
loopback, NAT through pf); managing jails from the host (jm jail ...) is
out of MVP scope (ADR 0006).
jm ssh -- bastille bootstrap 15.1-RELEASE
jm ssh -- bastille create demo 15.1-RELEASE 10.17.89.10
jm ssh -- bastille cmd demo pkg install -y curl
jm ssh -- bastille list all # "-a" is deprecated in bastille 1.4.4
jm ssh -- bastille destroy -a -y demo
bastille destroy -fis not the non-interactive form —-fonly forces unmounting datasets, so the command still prompts and then dies on the empty answerjm sshgives it ([ERROR]: Invalid input. Please answer 'y' or 'n').-yis "assume yes" and-astops the jail first.
Verified on this Mac against a running machine (podman 6.1.0, guest FreeBSD
15.1-RELEASE-p2 arm64, compat.linux.osrelease=5.15.0). Linux images need
--os=linux for pull and build; FreeBSD images — which come from Docker
Hub and GHCR — need no flag at all.
| Image | Flag | Result |
|---|---|---|
alpine, debian:trixie-slim, ubuntu:24.04, python:3-alpine, golang:alpine, hello-world |
--os=linux |
Works |
caddy:alpine |
--os=linux |
Works |
postgres:17-alpine |
--os=linux |
No — postgres --version runs, but initdb needs signalfd (v14+) or SysV shared memory (v13 and earlier), details |
nginx:1.31-alpine |
--os=linux |
Works with one config line: accept_mutex on; |
redis:alpine |
--os=linux |
Works with redis-server --ignore-warnings ARM64-COW-BUG |
node:22-alpine |
--os=linux |
No — every invocation but --version hangs, details |
dougrabson/freebsd15-minimal, dougrabson/freebsd14-minimal, ghcr.io/freebsd/freebsd-runtime:15.1 and :14.3 |
none | Work (native FreeBSD) |
The full matrix, both workarounds, and the script that produced it
(demo/hub-matrix.sh) are in
docs/USAGE.md.
| Capability | State |
|---|---|
| Native FreeBSD OCI images | Works — run and build, e.g. ghcr.io/freebsd/freebsd-runtime:15.1 |
| Linux images | Works through the Linuxulator, with --os=linux (podman) or the wrapper's default platform (jdocker) |
| Host directory sharing | Works — host paths appear in the guest at the same absolute path over 9p; defaults are your home tree, /Volumes, /private/tmp and $TMPDIR's root. Slow (~70 MB/s), utimes is a no-op, an inotify watch cannot be created on a share at all, and guest ownership and modes live in host xattrs |
| Container DNS matching the host | Works for external names — the host's own resolver answers for the guest, so VPN, split-horizon, /etc/hosts and .local names all match, and the Mac is host.docker.internal |
| Resolving another container by its name | No. The guest's podman uses the CNI backend and netavark is not packaged for FreeBSD, so the podman network has dns_enabled: false and nc: bad address 'redis' is what you get. Use a Pod (localhost), network_mode: "service:<name>", or --add-host/extra_hosts (#5) |
| Autostart | Works on demand: jpodman and jdocker start a stopped machine. There is deliberately no login agent — JM_AUTOSTART=0 opts out. A suspended machine is woken regardless: by the wrappers, jm start and jm ssh, and by a client of podman.sock or the SSH port while its sleeper runs |
| Idle memory | Works — a machine idle for 30 minutes (--idle-suspend) with no containers or jails is suspended to disk and its memory goes back to macOS; the restore measured 4.06 s to SSH on a 2048 MiB guest. Published ports do not wake it, and after a Mac restart only jm commands do. See idle suspend |
docker.io/nginx (Linux) |
Works with one config line: accept_mutex on; in the events block. Stock nginx registers its listening socket with EPOLLEXCLUSIVE when worker_processes > 1, which FreeBSD's linux_epoll rejects. A ready-made image is in demo/ |
Publishing ports (-p 8080:80) |
Works — reconciled onto the host by the forwarder, binding every interface by default (--publish-addr to change the default) |
-p 127.0.0.1:8080:80, -p [::1]:…, ranges, /udp |
Works — a host address in the flag binds that address on the Mac and nothing else, as under Docker Desktop. -p localhost:… is rejected by podman itself |
| Docker CLI and compose | Works via jdocker, or eval "$(jm env)" for a client you point yourself. Compose pulls FreeBSD variants under plain podman, so a Linux image needs jpodman pull --os=linux <image> first plus pull_policy: missing on the service |
| Jails | Works — bastille bootstrap, create, cmd, pkg install in the guest |
| Several machines at once | Works, but each needs its own SSH port — jm init --ssh-port 2223 dev, then JM_MACHINE=dev jpodman ps |
| UDP from a Linux container | Works — binding, sending, receiving, DNS-over-UDP and publishing with -p 5354:53/udp, verified from the Mac's loopback and its LAN address. One idiom fails: busybox's nc -u -l reports Address family not supported by protocol. Use apk add netcat-openbsd, socat, or any real UDP server. Datagrams are capped at 8972 bytes by the gvproxy link, which does not fragment: it is MTU 9000 (the virtio-net jumbo frame) by default, and JM_MTU at jm start moves the ceiling — JM_MTU=1500 restores Docker's link size and its 1472-byte cap. See UDP in a Linux container |
docker.io/node (Linux) |
Partly. node --version prints and exits 0; every other invocation hangs, node -e '' and node -p 1+1 included — they never reach your script. FreeBSD's linux_mremap cannot grow a mapping, which node's allocator retries forever. Workaround: node:22-bookworm-slim (glibc), verified working. See node |
| Routable VM IP | No. gvproxy is NAT; vmnet/bridged is a later step |
| Intel Macs, Linux and Windows hosts | No. Only darwin/arm64 has a backend; the Linux release binaries are build-only. Apple Virtualization.framework cannot boot FreeBSD/arm64, hence QEMU |
| Symptom | Do |
|---|---|
| Anything at all | jm doctor — checks every tool and machine and prints a fix per failure |
start hangs or fails at a stage |
The error names the stage and the log to read; jm console shows the guest's serial console (-f to follow the boot) |
| Provisioning failed | jm ssh -- cat /var/log/jm-provision.log; the marker /var/db/jm-provision-failed means the script aborted |
| Port not reachable | jm ports lists each mapping with its error (host port busy, loopback bind, forwarder down) |
-v fails source path does not exist, or mounts an empty directory |
The host path is outside the shared set (jm inspect), or you wrote /tmp/... instead of /private/tmp/.... A source that exists nowhere in the guest is an error; one that happens to exist there anyway binds the guest's own copy, which looks empty |
A container cannot resolve another container by name (nc: bad address 'redis') |
Container DNS is off — the guest's podman runs the CNI backend and netavark is not packaged for FreeBSD (#5). Put the containers in a Pod and use localhost, or network_mode: "service:<name>", or --add-host/extra_hosts |
| A name resolves on the Mac but not in a container | jm doctor, then resolver.log |
nc -u -l in a Linux container says Address family not supported |
Only busybox's UDP listener is affected — apk add netcat-openbsd, or use socat. UDP itself works |
| UDP datagrams over 8972 bytes never arrive | The gvproxy link does not fragment, so its MTU (9000 by default) is a hard ceiling; jm doctor states the limit per machine, and JM_MTU at jm start changes it (576–16384) |
| Stale state after a crash or reboot | jm stop repairs "broken" (pid file without process); jm rm && jm init is always a clean slate |
| A machine never suspends, or a client did not wake it | jm inspect lists Idle suspend: held awake by: …; sleeper.log and wake.log say what the sleeper and the wake did |
Host-side logs, all under ~/.jailmachine/machines/<name>/:
| File | From |
|---|---|
console.log |
guest serial console (jm console) |
qemu.log |
QEMU's own stdout/stderr |
gvproxy.log |
network provider |
forwarder.log |
port-publishing loop |
resolver.log |
host DNS resolver (name-resolution parity) |
sleeper.log |
the helper that suspends an idle machine and holds its endpoints while it is suspended |
wake.log |
wakes started by a connection to a suspended machine |
More symptoms and fixes in docs/TROUBLESHOOTING.md.
git clone https://github.com/gabrielbelli/jailmachine && cd jailmachine
make build && ./jm version
make test lint
JM_E2E=1 make e2e # full init -> start -> podman run -> stop -> rm, needs qemu + podman- docs/tutorials/ — four end-to-end walkthroughs: first container, shared-folder dev loop, a compose/kube stack, and native FreeBSD images and jails
- docs/INSTALL.md — install paths, requirements, uninstalling
- docs/USAGE.md — every command, flag and environment variable
- docs/ARCHITECTURE.md — how the pieces fit together
- docs/TROUBLESHOOTING.md — symptoms, logs and fixes
- docs/LIMITATIONS.md — everything it cannot do, attributed and measured
- docs/COMPARISON.md — against Docker Desktop and podman machine, on one Mac, in numbers
- demo/README.md — five published demo images that prove FreeBSD and Linux containers side by side, including a Docker Hub nginx serving traffic under the Linuxulator
- docs/adr/ — decisions; docs/tech-choices.md for the concrete tools
- CONTRIBUTING.md — build, test, release
BSD-2-Clause. See LICENSE.
Tracked, with measurements, in the issue tracker:
| Issue | Effect | Workaround today |
|---|---|---|
| #2 UDP datagrams larger than the link MTU are dropped | gvproxy does not fragment: the ceiling is 8972 bytes at the default MTU, where Linux delivers 65507. Native FreeBSD containers hit the same wall, so it is the link, not the Linuxulator | JM_MTU (576–16384) moves the ceiling; jm doctor states it per machine |
| #3 Healthchecks never run | A podman-on-FreeBSD gap: healthchecks are scheduled with systemd transient timers and there is no systemd, so --health-interval never fires and the status sits at starting with zero log entries. A bare-metal FreeBSD container host behaves the same. Restart policies do work |
jm ssh -- podman healthcheck run <name>, or a cron entry in the guest |
| #4 an inotify watch cannot be created on a 9p share, which also runs at ~70 MB/s | Not missing events — inotify_add_watch on a shared path fails outright with Bad file descriptor, so the watch is never created; it works on an engine-managed volume and on the container's own filesystem. Reads are coherent immediately. Metadata is the bigger cost: 1000 small files take 3.6 s on a share against 0.76 s on the guest's own disk |
Use polling watchers (CHOKIDAR_USEPOLLING=1, nodemon --legacy-watch, --watch.usePolling); keep build output in an engine-managed volume |
| #5 containers cannot resolve each other by name | A podman-on-FreeBSD gap: the guest runs podman's CNI backend because netavark is not packaged for FreeBSD, and the CNI dnsname plugin is not packaged either, so podman network inspect podman reports dns_enabled: false and a sibling lookup fails with nc: bad address 'redis'. aardvark-dns is in the repo but is useless without netavark. This breaks the default shape of most compose files |
Put the containers in a Pod — they share a network namespace, so localhost works, which is what kube play gives you. (Podman also writes pod mates' container names into /etc/hosts, but under kube play that is <pod>-<container>, not the manifest's name: — so use localhost.) Otherwise network_mode: "service:<name>" in compose, or extra_hosts/--add-host with the container's bridge IP |