Co-authored-by: claude-code <claude-code@javagil.de>
Werkdock
A docker-like sandbox CLI over bwrap — filesystem isolation only.
A dock is the enclosed basin in which ships are built: the dock gate controls what passes, the water outside is shared with the whole harbor.
Accordingly, network, uid, /proc, /dev, and /tmp come from the host by contract; that is what makes Werkdock work without root on a Hostsharing Managed Webspace.
Semantics — docker-compatible as far as the filesystem-only contract allows (see RFC 0002):
-
An image is a rootfs archive; an instance is an unpacked, writable directory tree and corresponds to a docker container.
-
importtakes a plain rootfs archive, asdocker importdoes;loadtakes adocker savearchive or an OCI image layout, flattens the layers (whiteouts honored, paths resolved inside the rootfs as in a chroot) and keeps the OCI config — so a Dockerfile-built image lands with its ENTRYPOINT, CMD, ENV, and WORKDIR (RFC 0004).load -i … --namestill routes a rootfs archive toimport, for callers that predate it.pullfetches an image from a registry the same way, with the registry side (auth, manifest lists, blobs) delegated to the crane binary — a single unprivileged executable found inPATH, besidewerkdock, or atWERKDOCK_CRANE; it reads~/.docker/config.json(DOCKER_CONFIG) for credentials as docker does.imageslists one reference per line by default (what a script matches against) and takes docker's--format, with--format tablegiving docker's columns;rmi IMAGE...removes images. -
buildbuilds an image from a Dockerfile on this host —werkdock build [-f Dockerfile] -t IMAGE CONTEXT, docker's flags (RFC 0006).FROMresolves through the store and pulls on a miss, one writable work tree is created over the base, the steps run against it, and the result is an ordinary flat image;FROM scratchstarts from an empty tree. Stage 1 isFROM,RUN,COPYplus the metadata instructionsimport -calready knew;ARG,SHELL,ADDand multi-stage builds name the stage of RFC 0006 that brings them, andEXPOSE,HEALTHCHECK,STOPSIGNAL,ONBUILDand BuildKit'sRUN --mountare refused with the reason at parse time. Werkdock sets the two levers an unprivileged single-uid sandbox needs itself, so the Dockerfile stays portable:APT::Sandbox::User rootfor the duration of the build, andfakerootwithFAKEROOTDONTTRYCHOWN=1around everyRUNstep — which means the base image needsfakerootinstalled, or a package whose postinst chowns fails. There is no build cache: every build starts atFROM, because caching a step without a union mount would cost a whole tree copy. That changes with the overlay backend, after the webspaces move to Debian 13 (bwrap 0.11). -
An image carries the execution-relevant OCI metadata (
Entrypoint,Cmd,Env,WorkingDir,User,Volumes,Labels, architecture, os), set atimport -c 'ENTRYPOINT [...]'the waydocker import --changedoes;inspectprints it docker-shaped, andrunwithout a command executesENTRYPOINT+CMD. -
The caller is the sandbox's only user — an unprivileged user namespace maps exactly one uid. Which one is docker's choice:
--user USER[:GROUP](a name from the image's/etc/passwd, or a number), else the image'sUSER, else uid 0. Podman's--userns=keep-idmaps the caller to their own uid. Service images that refuse root (PostgreSQL) run this way (RFC 0005). -
The image's
VOLUMEpaths get an anonymous volume unless a-v/--mountcovers them: a directory of the instance, kept acrossstop/start, gone withrm— docker's anonymous volumes./run(the target of/var/run) is an instance directory as well, so services keep pid files, locks, and sockets there without declaring it andexecfinds the socket;/tmpand/rootare tmpfs per run. -
Images are named the docker/OCI way,
[registry/]repository[:tag](eclipse-temurin:21,ghcr.io/example/build:2026.09), the tag defaulting tolatest, so build configurations can use one reference for both. -
The network is the host's;
--network hostsays so in docker's words and is accepted as a no-op, every other mode and-pare refused with the reason. -
Mounts are written as
-v SRC:DEST[:ro],--tmpfs DEST, or docker's explicit--mount type=bind|tmpfs,...; all three feed one ordered mount list. -
werkdock run [flags] IMAGE [CMD...]creates an instance and executes in the sandbox; verbs and flags follow docker, unsupported docker flags fail loudly. -
Instances have docker's lifecycle (RFC 0005 milestone 2):
run -dstarts a service under a small supervisor process and prints the id;ps [-a],logs [-f],stop [-t],kill,wait,rm [-f],create/start,inspectwork on ids, unique prefixes, or--names;exec INSTANCE CMDruns a second command in the same sandbox (same rootfs, mounts, uid,/run, volumes — not the same pid namespace). Instance state is one directory each underinstances/:config.json,state.json,stdout,stderr,run/,volumes/.--rmforeground runs behave as before and record no logs. The command is pid 1 of its sandbox, as in docker:stopreaches it with SIGTERM if it handles the signal, SIGKILL follows after the timeout. -
werkdock doctorchecks the host: user-namespace capability, a writable root bind and the build backend it gives, disk and quota headroom. -
werkdock system serviceserves the Docker Engine API subset on a unix socket, so a client that speaks docker uses werkdock without knowing it. Testcontainers does:PostgreSQLContainerstarts, waits, executes, and stops against werkdock (RFC 0005 milestones 3 and 4). The endpoints are the ones measured from a real run (docs/testcontainers-api.md); everything else answers 501 with the reason. How to use it: docs/testcontainers.md. -
Ports are the one deliberate deviation: the sandbox shares the host's network, so werkdock cannot map a port. It picks a free one and tells the service to bind it through the image's own environment variable (
PGPORTand its kin); an image outside that table keeps its port.
Disk Footprint
Werkdock's storage model is coarser than Docker's on the image side and cheaper on the instance side:
- An image is a flat, complete directory tree — there are no layers, and nothing is shared between images.
A JDK+Go+Node build image is roughly 2 GiB unpacked, plus its compressed archive (~0.5 GiB) as long as that is kept around.
"No layers" means three different things, which RFC 0006 separates: no union mount (the only one that ever needed a kernel feature, and only because bwrap 0.8 has no overlay options and the sandbox cannot mount anything itself —
CapEffis 0, measured), no storage dedup between images (which needs nothing from bwrap; werkdock already hardlinks the second tag of one image), and no layer blobs kept as a transport and cache format (a decision, and the one with the highest running cost: everypullof a new tag of the same base refetches and unpacks everything). - A
buildcosts one full tree copy, and peak disk is base plus work tree until the commit. A real copy, not hardlinks: aRUNstep writing in place through a hardlink would modify the base image. Measured: 0.31 s for 129 MiB over 5448 inodes on local ext4, 6.8 s for a whole build over a 143 MB base with anapt-get install nginx jqin it; for a ~2 GiB build image on shared webspace storage budget tens of seconds. - An instance costs (almost) nothing: the rootfs is bound read-only into every sandbox, writable are only tmpfs (
/tmp,/root), the instance's ownrun/andvolumes/directories, and the caller's binds. Ten parallel runs in one image add zero filesystem copies; what grows per project are its own caches in bound volumes, and per service its data volume untilrm. - Consequence: prefer ONE fat image shared by all projects over per-project images.
- Watch out for orphans: consumers that key an unpacked environment by the archive's source path (Werkator's bwrap runtime does) leave the old tree behind on every path change; pruning is manual until
rmi/pruneverbs exist. - Future options that would remove the flat-tree cost, in their own RFCs when they come due: composable toolchain mounts — a slim base plus per-toolchain prefix binds, no overlayfs needed (RFC 0003, candidate; docker's
--mount type=imageis the compatible spelling, RFC 0004) — overlayfs layers as a union mount (the kernel allows it unprivileged in a user namespace since 5.11; the webspaces' bwrap 0.8.0 has no overlay options, bwrap 0.11 on Debian 13 does, measured — it buys a per-step build cache and disk space, not run-time, since a flat tree is bound read-only into every run for free), or hardlink deduplication between image versions in the store (the ostree principle, no root needed, available today).
Changes that matter to consumers of the CLI (Werkator) are recorded in the CHANGELOG, with what stays compatible and what to adapt.
Build and Test
go test ./... # all tests; sandbox integration tests skip without bwrap/userns
go vet ./... && gofmt -l . # quality gates (gofmt must print nothing)
CGO_ENABLED=0 go build . # one static linux binary, ~3 MB
First steps on a host:
werkdock doctor # can this host run sandboxes?
werkdock import -c 'CMD ["bash"]' rootfs.tar.zst IMAGE # create an image from a rootfs archive (docker import semantics)
werkdock load -i image.tar # load a docker save / OCI archive (docker build … && docker save -o image.tar …)
werkdock pull eclipse-temurin:21 # fetch from a registry (needs the crane binary next to werkdock)
werkdock build -t buildenv:trixie . # build from ./Dockerfile (the base image needs fakeroot installed)
werkdock images --format table # REPOSITORY TAG IMAGE ID CREATED SIZE, as docker prints it
werkdock rmi eclipse-temurin:21 # remove an image (docker rmi)
werkdock inspect IMAGE # its metadata, docker-shaped JSON
werkdock run --rm -v /repo:/repo -w /repo IMAGE sh -c './gradlew build'
werkdock run -d --name pg --user postgres -e POSTGRES_PASSWORD=x -e PGPORT=54329 postgres:16-alpine # a service
werkdock ps; werkdock logs -f pg; werkdock exec pg psql -U postgres -c 'select 1'; werkdock stop pg; werkdock rm pg
For Testcontainers, run the socket and point a client at it:
werkdock system service & # $XDG_RUNTIME_DIR/werkdock.sock
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/werkdock.sock
export TESTCONTAINERS_RYUK_DISABLED=true
Status: bootstrap.
The implementation language is Go, decided in RFC 0001.
Werkdock grew in the werkdock/ subdirectory of the Werkator repository and
moved here once it stood on its own (Werkator plan step 21, session E); the history above the split is preserved.
It stays self-contained: no imports from Werkator code, no coupling to the Werkator build — Werkator consumes the
binary through PATH (bwrap.werkdock), the way it consumes git.
Its own CI is the .werkator.yml in this repository; the roadmap that created it is session B of Werkator's
plan step 21.