Run Linux containers on macOS — with no VM.
dd runs Linux containers natively on Apple-Silicon macOS without a virtual machine. There is no
Linux kernel and no hypervisor underneath: a JIT translates the container's code and services its
Linux syscalls in userspace (the gVisor / PRoot lineage). The JIT is the guest's Linux kernel —
namespaces, cgroups, overlay image layers and networking are maintained as userspace state. It speaks
the Docker Engine API, so the ordinary docker CLI drives it.
The container's compute runs as native Apple-Silicon instructions; only its syscalls are interpreted. No VM to boot, no daemon-in-a-VM, no virtualization cost.
Website & docs: https://ricccrd.github.io/dd/
make jit # build.rs compiles + codesigns the JITs
DD_IMAGES=/path/to/images cargo run -p dd-daemon # start the daemon
export DOCKER_HOST=unix://$PWD/dd.sock
docker run -p 8080:80 -m 256m alpine sh -c 'echo hi from $(hostname)'- No virtual machine. No hypervisor, no Linux kernel, no VM to keep resident. The guest's instructions run natively on arm64; only the syscall boundary is trapped and serviced in userspace.
- Drop-in Docker. dd implements the Docker Engine API. Point
DOCKER_HOSTat its socket and your existingdocker run / ps / images / buildcommands work unchanged. - The JIT is the kernel. Namespaces, cgroups, overlay image layers and networking are ordinary userspace state — a userspace kernel in the gVisor / PRoot lineage, with none of a VM's cost.
- Three guest runtimes, one engine. Native arm64 Linux images; x86-64 Linux images via a JIT
(
jit86) that decodes x86, synthesizes its flags, and lowers SSE/x87 onto NEON (glibc binaries run); and macOS arm64 guests (ddcli mac) — no VM in any of them. - Real container isolation. Overlay image layers (copy-up /
.wh.whiteout, mergedgetdents), a TOCTOU-free path-jail VFS, PID / UTS / USER namespaces, a private loopback netns with-pport publishing, and cgroup memory + pids limits (OOM at the limit). - Desktop app, no root. A native GTK4 app (dd-app) plus a
ddCLI install a per-user background daemon and adocker context— everything under$HOME, neversudo.
Every other way to run Linux containers on a Mac — Docker Desktop, Colima, Rancher, OrbStack — boots a Linux VM under a hypervisor and runs the daemon inside it. That VM is a tax you pay all day. dd deletes it: a container is a plain macOS process whose syscalls happen to be serviced by a userspace Linux kernel.
| dd — userspace kernel (JIT) | VM-based Docker (Desktop / Colima / …) | |
|---|---|---|
| Underlying model | A JIT services Linux syscalls in userspace (gVisor lineage) | A full Linux kernel inside a hypervisor VM |
| Resident RAM when idle | None — per-container, freed on exit | Gigabytes reserved for the VM, always on |
| Startup | Process spawn — no VM to boot | Boot a Linux VM + the in-VM daemon first |
| Bind-mount / file I/O | Direct host filesystem through a path jail | virtiofs/gRPC-FUSE bridge across the VM boundary |
| Port publishing | Straight to host sockets | Through the VM's NAT/forwarding layer |
| Battery / background cost | Nothing running when no container is | A VM idling and draining battery |
| Footprint to ship & patch | No Linux kernel — nothing to CVE-track | Ships, patches and tracks a whole Linux kernel |
| Observability | A normal macOS process — sample, debug, Activity Monitor | An opaque VM; the workload is invisible to host tools |
The win is structural: the guest's compute runs as native Apple-Silicon instructions (no
hardware-virtualization layer in the hot path), and the notorious Docker-Desktop file-sharing
bottleneck — the virtiofs/FUSE bridge between macOS and the VM — simply doesn't exist, because dd's
VFS is the host filesystem behind a path jail.
Honest trade-off: a userspace kernel is only as complete as the syscalls it implements, and today By default the guest runs in one process — fast, and the right call for code you trust (your dev environment, CI, your own tools). For untrusted code there's now an opt-in sentry split (
DDJIT_UNTRUSTED): the guest runs in a deny-default Seatbelt sandbox holding no host fs/net authority, while a trusted sentry process owns the real resources and serves syscalls across a shared-memory ring — the gVisor shape. It's early (the core file syscalls — read/write/open/close/lseek — forward today; sockets/exec/fork are landing), so for fully hostile code a VM still exposes a narrower surface.
The same static Linux binary, run two ways on an Apple M5 Pro (macOS 26.3): inside the Linux VM
(how VM-based Docker runs containers) vs. through dd's JIT on the host with no VM. Median of 7
(make bench). Lower time is better; "dd vs VM" > 1× means dd is faster. The dd lane even pays a small
cross-process bridge tax the real app doesn't — so these are conservative.
x86-64 containers — dd vs VM emulation (qemu-user; running x86 on Apple Silicon means translating it either way). dd's JIT beats qemu on 9 of 10 workloads, dramatically on floating-point:
| Workload | VM (qemu) | dd (no VM) | dd vs VM |
|---|---|---|---|
| float n-body | 5.18s | 0.20s | 26× faster |
| matmul | 8.08s | 0.65s | 12× faster |
| mandelbrot | 7.62s | 0.81s | 9.4× faster |
| SQLite (600k rows) | 2.88s | 0.73s | 4.0× faster |
| qsort | 3.86s | 1.36s | 2.8× faster |
| memcpy | 2.30s | 0.92s | 2.5× faster |
| int sieve | 1.26s | 0.63s | 2.0× faster |
| text-scan (wc/grep) | 1.36s | 0.88s | 1.6× faster |
| SHA-256 | 2.60s | 1.83s | 1.4× faster |
| base64 | 4.10s | 4.71s | 0.87× (1.15× slower) |
aarch64 containers — dd vs a native VM (the VM runs arm64 at full native speed — the hardest bar):
| Workload | VM (native) | dd (no VM) | dd vs VM |
|---|---|---|---|
| int sieve | 0.74s | 0.48s | 1.55× faster |
| mandelbrot | 0.76s | 0.74s | 1.03× faster |
| matmul | 0.63s | 0.64s | ~parity |
| memcpy | 0.53s | 0.54s | ~parity |
| base64 | 0.65s | 0.65s | ~parity |
| float n-body | 0.16s | 0.17s | ~parity |
| SHA-256 | 0.77s | 0.80s | ~parity |
| qsort | 0.79s | 1.05s | 1.33× slower |
| text-scan (wc/grep) | 0.49s | 0.66s | 1.35× slower |
| SQLite (600k rows) | 0.35s | 0.52s | 1.48× slower |
dd runs arm64 compute at native speed — ahead on int sieve + mandelbrot, at parity on SHA-256, matmul,
memcpy, n-body, and base64. The remaining gaps are indirect-branch / syscall-heavy work — qsort (~1.3×),
text-scan (~1.35×) and SQLite (~1.5×) — narrowed sharply by the latest passes (§B-off + stolen x16/x17 took
SQLite from ~1.9× to ~1.5×). Closing the rest (VDBE dispatch) is the active frontier; see
docs/design/arm-sqlite-parity.md. (Every
workload is sized to run ≥0.45s, so the harness's small per-run bridge tax is negligible here.)
These are compute micro-benchmarks — they don't even capture dd's structural wins (no VM to boot, no
resident RAM, direct host-filesystem I/O). All numbers measured, median of 7. Reproduce: make bench.
The goal is to beat the VM on every benchmark. dd already wins every x86-64 workload above and matches or beats native arm64; where it's still behind — syscall/allocation-heavy arm64 SQLite, and squeezing more out of the x86 translator — is exactly the optimization frontier (the tier-2 trace optimizer and the jit86 perf work). Parity-or-better everywhere is the bar.
dd runs a Linux container by being its kernel in userspace. A JIT translates the guest's machine
code and traps every syscall instruction; the trap handler — service() in dd-jit/src/runtime/os/linux/
— is the Linux syscall ABI, implemented against the macOS host.
- Load the guest ELF (static-PIE, or dynamic via its
ld.so) and build the initial stack. - Translate & dispatch the guest PC block-by-block; same-ISA code is mostly transliterated, x86-64 is decoded and re-emitted on arm64.
- Run the translated block as native host code until a terminator (branch / indirect jump / syscall).
- Service the syscall — every path passes through the container VFS jail; namespaces and cgroups are just process state.
# 1. Start the daemon, point docker at it
make jit
DD_IMAGES=/path/to/images cargo run -p dd-daemon
export DOCKER_HOST=unix://$PWD/dd.sock
# 2. It's just Docker
docker run -p 8080:80 -m 256m alpine sh -c 'echo hi from $(hostname)'
docker ps
docker images
docker run --rm -it ubuntu bash
# 3. Or via the installed desktop app (per-user, no root)
dd install # LaunchAgent + docker context
dd app # open the GUI
docker --context dd run alpine echo hidd targets Apple-Silicon macOS (arm64, macOS 12+). The JIT needs the Xcode Command Line Tools
(clang + codesign).
Grab the latest .dmg from the releases page,
open it, and drag dd to Applications. Then in a terminal:
dd install # ~/.dd tree + per-user LaunchAgent + `docker context create dd`
dd app # open the GUI
dd doctor # check socket / agent / context / app quarantineGatekeeper: the DMG is unsigned (ad-hoc). On first launch, right-click the app → Open, or run
xattr -dr com.apple.quarantine /Applications/dd-app.app(dd doctordetects this and prints the fix).
xcode-select --install # clang + codesign
# install Rust (stable) and Nix (for the GTK4 dev shell)
git clone https://github.com/ricccrd/dd && cd dd
make app # build + assemble & ad-hoc-sign target/dd-app.app
make dmg # -> target/dist/dd-<ver>-<arch>.dmg
make install # copy to /Applications and run `dd install`make app/dmg run the bundling inside the Nix dev shell (nix/flake.nix), which
provides GTK4 + dylibbundler / create-dmg. The bundle relocates the GTK dylib graph into
Contents/Frameworks, stages the GTK runtime data, and ad-hoc-signs inner→outer.
A Cargo workspace.
dd-jit/— the JIT runtime (C, undersrc/runtime/) plus its Rust bindings.build.rscompiles and codesigns one JIT binary per guest architecture (aarch64,x86_64);src/lib.rsexposesGuest+ the typedSpawnConfiglaunch contract. The aarch64 guest is fully decomposed (jit/engine +os/linux/personality +frontend/aarch64/); the x86-64 guest (jit86) shares theos/linux/layer.dd-daemon/— the Docker Engine API daemon. Detects each image's guest architecture from its ELF, picks the matching JIT, and launches it viaSpawnConfig.dd-tests/— a declarative test harness; cases run across every engine with a grouped report.dd-client/— a small typed Docker-Engine-API client over the daemon's Unix socket (the single source of truth for the wire format, shared by the GUI and CLI).dd-gui/(binarydd-app) — a GTK4 desktop UI. Built only on macOS via the Nix dev shell.dd-cli/(binarydd) — the install/control surface, all without root.
The daemon listens on ~/.dd/run/docker.sock; both the GUI and docker --context dd use it. State
persists to ~/.dd/state.json.
make test # the engine × case matrix, grouped report
make test ENGINE=x86_64 # one engine
make test FILTER=container # one group / cases matching a name
cargo run -p dd-tests -- --list # list groups + cases
make test-ci # the cargo-test path (CI)Cases are declared in dd-tests/src/cases/. A case is a guest program + assertions; aarch64 guests are
compiled on the fly (gcc -static-pie) and diffed against a native oracle, x86-64 guests come from
prebuilt fixtures. Each case runs on every engine it has a guest for.
- Guest: Linux aarch64 (decomposed, full container engine) + x86-64 (jit86, runs glibc).
- Host: macOS arm64 (Apple Silicon). The JIT needs
clang+codesign(Xcode CLT). - Containers: rootfs + overlay image layers (copy-up/whiteout), bind volumes, port publishing
(
-p), private-loopback netns, cgroup memory+pids limits, UTS/PID/USER namespaces. - Roadmap: OCI registry pull/unpack, the jit86 dedup onto the shared engine, a full external
netstack, and the sentry split for untrusted images. See
docs/for the detailed write-ups.
Richard Hutta — huttarichard@gmail.com
MIT.
