0.9.0 listed mosquitto as the image werkdock cannot move, and that was true of everything mosquitto offers: -p is ignored once a config file declares a listener (the manual says so, and it was measured), no environment variable names the port, and the config file expands none. What mosquitto does read is the config file — and a config file is the one thing werkdock can replace that a flag or a variable cannot reach. A quirk can now name a PortFile: which file the image ships carries the port and how the line reads. Werkdock copies that file into the instance directory with the line rewritten to the chosen port and binds the copy read-only over the image's original, which is never touched. Both of mosquitto's listeners live in the same file, so the dashboard's HTTP API on 9883 moves along with MQTT on 1883. Measured with 2.1.2 through the API socket: two instances at once, each on its own pair of ports, each accepting an anonymous client and answering on its dashboard, 1883 left free. The rewritten copy applies only while the command still names the file and the client has not bound a file of their own over that path; either way the copy would not be what the server reads, and the port is then reported unmoved. An image whose file no longer has the expected line is refused at create with the line named, not started on a port werkdock only claimed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.- 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, 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.
- 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 (the kernel allows it unprivileged in a user namespace since 5.11; the webspaces' bwrap 0.8.0 cannot yet), or hardlink deduplication between image versions in the store (the ostree principle, no root needed).
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 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.