# RFC 0006: Dockerfile Builds **Status:** - proposed: 2026-10-17 - accepted: - - rejected: - **Proposal:** `werkdock build` builds an image from a Dockerfile on the target itself. `FROM` resolves through the store, one writable work tree is created over the base, the steps run against it, the metadata instructions go into `image.json`, and the result is an ordinary flat werkdock image staged and renamed the way `import` already does. Everything that depends on the bwrap version lives behind one interface, `build.Backend`, with two implementations: a **copy backend** that works on today's bwrap 0.8 and an **overlay backend** that becomes possible with the Debian 13 upgrade and brings the per-step cache with it. Parser, instructions, `COPY`, multi-stage, CLI, and the uid levers are identical in both worlds. ## Context and Problem Statement [RFC 0004](0004-docker-compatible-image-creation.md) listed `build` as "not planned", because it "needs writable rootfs snapshots per step and `pull` for FROM". Both halves of that reason have expired: - `pull` has been there since 0.3.0. - Per-step snapshots are what a *build cache* needs, not what a *build* needs. One writable tree and a commit at the end are enough for a correct build; the cache is a separate, optional gain. What has not expired is the operational problem the "not planned" left behind: the Hostsharing Managed Webspaces run neither Docker nor Podman, so every image werkdock runs has to be built somewhere else and uploaded. That is one more machine in the loop for every change to a build environment, and the one place where werkdock's "docker-compatible as far as the contract allows" surface simply stops. A second thing has changed. In a few months the webspaces move to Debian 13 trixie, whose `bubblewrap` is 0.11.0-2+deb13u1 (security-updated to 0.12.0-1~deb13u1). bwrap gained `--overlay-src`/`--overlay` in 0.9. So the measurements below were taken on both sides of that line, and the central question of this RFC is not "can werkdock build" — it can — but **where to put the boundary so that the work done for bwrap 0.8 is not thrown away after the upgrade.** ## What Was Measured Everything in this section was measured, not reasoned about. Setup: a `debian:trixie` rootfs, `bubblewrap 0.11.1`, an unprivileged user namespace with `uid_map` `0 1` — the single mapping the webspaces give (RFC 0005, `internal/doctor`) — on ext4 and tmpfs. **A writable root works.** `--bind /` in place of `--ro-bind` is accepted unprivileged; `touch /usr/probe` inside the sandbox succeeds. Nothing else about the invocation has to change. **The sandbox has no capabilities at all.** `CapEff: 0000000000000000`. Consequences, both measured: `mount -t tmpfs none /mnt/x` fails with `permission denied` (exit 32) and `mknod /tmp/node c 1 3` with `Operation not permitted`. **The process inside the sandbox can mount nothing itself; every mount must stand in bwrap's argv.** That is the real reason "no layers" has been a limit — not the store format. **apt fails on its own sandbox user.** Plain `apt-get update`: ``` E: seteuid 42 failed - seteuid (22: Invalid argument) E: setgroups 65534 failed - setgroups (1: Operation not permitted) E: Method gave invalid 400 URI Failure message: Failed to setgroups E: Method http has died unexpectedly! E: Sub-process http returned an error code (112) ``` apt drops to the user `_apt` (uid 42) to fetch; there is no uid 42 in the map. With `APT::Sandbox::User "root"` — measured both as `-o` on the command line and as a fragment in `/etc/apt/apt.conf.d/` — `update` and `install jq` run through, leaving one cosmetic warning: `W: chown to root:adm of file /var/log/apt/term.log failed - OpenLog (22: Invalid argument)`. **That is not enough for every package.** `apt-get install nginx` still fails: ``` chown: changing ownership of '/var/log/nginx': Invalid argument dpkg: error processing package nginx-common (--configure): installed nginx-common package post-installation script subprocess returned error exit status 1 ``` (exit 100). `nginx-common.postinst` chowns to `root:adm`; group 4 is not in the map. **`fakeroot` alone does not fix it.** With `fakeroot --` in front of the same command, libfakeroot is demonstrably active — `LD_PRELOAD=libfakeroot-sysv.so`, `FAKEROOTKEY` set, `ls -ln` inside reporting the faked owner — and the install fails with the *same* `chown: ... Invalid argument`, exit 100. fakeroot's `chown` wrapper calls the real syscall first and reports its `EINVAL` before recording the fake ownership. **`fakeroot` with `FAKEROOTDONTTRYCHOWN=1` fixes it.** `nginx-common` and `nginx` configure cleanly, exit 0, `nginx -v` answers `nginx/1.26.3`, `dpkg -l nginx` shows `ii`. Inside the fakeroot `/var/log/nginx` reads as `0:4`; on the host afterwards it is the caller's own uid. **The faked ownership is lost at commit, and that loses nothing:** `internal/store/layers.go` never restores ownership when it unpacks a layer either, because the sandbox has exactly one uid to restore it to. **Overlay works unprivileged on 0.11, and the upper directory is already a layer diff.** `--overlay-src BASE --overlay UPPER WORK /` mounts; `--overlay-src` stacks, verified with a second step over the first (`--overlay-src BASE --overlay-src UPPER1 --overlay UPPER2 WORK2 /`), which saw step 1's new file and step 1's deletion. `UPPER1` afterwards held exactly the diff: ``` -rw-rw-r-- 1 6 etc/step1.txt # changed file c--------- 1 0,0 etc/hostname # deletion, as a character device 0:0 ``` A character device 0:0 is precisely what OCI writes as `.wh.NAME` — the whiteout convention `layers.go` already reads. Two snags, both measured. The overlay work directory keeps a `work/` subdirectory at mode `d---------`, which its own owner cannot read, so cleanup has to `chmod` it before removing it. And bwrap's NEWS states that overlay is unavailable when bwrap is installed setuid root — moot from 0.12 on, which dropped setuid support altogether, but a real reason to probe rather than assume. **With a writable root, bwrap creates a missing bind mountpoint itself — and it stays in the tree.** Measured: a `--ro-bind` of a host file to `/probe.conf` left `/probe.conf` behind in the work tree (mode `r--r--r--`) after the sandbox exited. A build must therefore not bind anything it does not want in the finished image. **A tree copy costs what a tree copy costs.** 129 MiB over 5448 inodes, `cp -a` within ext4 on a local SSD with a warm page cache: **0.31 s**; onto tmpfs 0.24 s. The same tree with hardlinks (`cp -al`): 0.07 s. See *Costs* below for what that extrapolates to, and what it does not. **The whole thing works end to end.** The implementation under this RFC, measured on the same host: a 143 MB `debian:trixie` base with fakeroot, a Dockerfile of `FROM`/`ENV`/`WORKDIR`/`COPY`/two `RUN`/`LABEL`/`CMD` with `apt-get install -y nginx jq` in it — **6.8 s** wall for the full `werkdock build`, tree copy and apt install together. `nginx-common` configured, `dpkg-query` in the finished image reports `install ok installed`, and `werkdock run --rm probe:1` executes its `CMD`. ## The Build Model 1. **`FROM` resolves through the store.** The reference is looked up with `store.Dir`; a miss is pulled (`internal/registry`, i.e. crane), as `docker build` pulls a missing base. When no registry client is available the error names `werkdock pull` and `werkdock load` rather than guessing. 2. **One writable work tree** is created over the base image's rootfs — how, is the backend's business (next section). 3. **The steps run in order against that one tree.** `RUN` is a sandbox over the tree with the root bound read-write; `COPY` writes into the tree from the build context on the host, with no sandbox involved. 4. **Metadata instructions do not touch the tree.** `ENTRYPOINT`, `CMD`, `ENV`, `WORKDIR`, `USER`, `VOLUME`, `LABEL` go through `store.ApplyChange` into the `ImageConfig` that becomes `image.json` — the same function `import -c` has used since 0.2.0, so a Dockerfile line and a `--change` line are parsed by one piece of code or by neither. 5. **The result is an ordinary flat werkdock image.** No new store concept, no new artifact kind; `images`, `inspect`, `rmi`, `run`, `--mount type=image` all apply unchanged. 6. **Staging and commit are the mechanics `store.Import` already has:** build into `.tmp`, hold the per-image `flock` from `lockImport` across the commit, `rename` into place at the end. A failed or interrupted build leaves no half image, and a build racing an `import` of the same reference waits for it instead of deleting its files — the bug 0.11.0 fixed for `import` must not be reintroduced here. `ENV` is the one instruction that is both: it belongs in `image.json` *and* has to be in the environment of the `RUN` steps that follow it, as in docker. Two details the implementation forced, both worth writing down because they change behaviour: - **A build step gets no implicit tmpfs.** Every `run` mounts a tmpfs over `/tmp`, `/root` and `/run`, because a read-only root has no writable place otherwise (RFC 0005). A build step's root *is* writable, so it needs none — and must not have them, or what a `RUN` step writes to `/tmp` or `/root` would be gone in the next step while docker keeps it in the image. `~/.npmrc` written in one step and read in the next is a common enough idiom that the alternative is a bug factory. So the implicit tmpfs mounts are tied to the read-only root. - **A build binds as little as possible.** With a writable root bwrap creates a missing bind mountpoint itself, and the mountpoint stays in the tree (measured). The only bind a `RUN` step gets is the host's `/etc/resolv.conf`, read-only, as `run` does — and only where the tree already has the file, so nothing is left behind; a step over a base without one, e.g. `FROM scratch`, runs without the host's resolver. Anything else a build step might want mounted would have to earn a `build` flag first. ## The Backend Interface This is the part the rest of this RFC exists for. The build splits into one piece that depends on the bwrap version and everything else, which does not: ```go // Backend opens the writable work tree a build's steps run against. // It is the ONLY part of the build that depends on the bwrap version: // the parser, the instructions, COPY, multi-stage, the CLI, the uid // levers, and the commit are the same behind either implementation. type Backend interface { // Name identifies the backend in build output and in doctor. Name() string // CachesSteps reports whether a step's result can be kept and // reused by a later build. Only an overlay backend can. CachesSteps() bool // Open prepares a tree over base (an image's rootfs) below dir, // which the caller owns and removes. Open(base, dir string) (Tree, error) } // Tree is one build's writable rootfs. type Tree interface { // Step prepares step n (0-based, in build order). Step(n int) (Step, error) // Commit returns the host path of the finished flat rootfs; the // caller renames it into the store and owns it afterwards. Commit() (rootfs string, err error) // Close releases what the tree holds — mounts, overlay work // directories, staging it did not hand over. Close() error } // Step is how one build step reaches the tree. type Step interface { // Root sets the root-of-sandbox fields of a RUN step's spec. // The copy backend sets RootFS plus WritableRoot; an overlay // backend sets the overlay sources and this step's upper // directory. Nothing outside the backend may set them. Root(spec *engine.RunSpec) // Writable is the host directory this step's own file writes land // in, with the image's paths below it: the work tree for the copy // backend, the step's upper directory for an overlay backend. Writable() string } ``` The boundary is exactly "how does a writable tree over a base come into being, and how is its result harvested". Two consequences are worth stating, because they are what makes the cut load-bearing: - **No caller ever sets `RunSpec`'s root fields.** They go through `Step.Root`. Adding overlay fields to `RunSpec` later therefore touches `engine`, the overlay backend, and nothing else. - **`Writable()` is a host directory, not a tree.** For the copy backend it is the work tree; for the overlay backend it is the step's upper directory, which is where a `COPY` of a *whole file* has to land anyway. This is correct for `COPY`/`ADD`, which only ever create or replace whole files. It would be wrong for a deletion, which in an upper directory is a character device 0:0 — and `mknod` is `EPERM` in the sandbox (measured). No stage-1 to stage-3 instruction deletes, and the overlay backend's own commit does the whiteout reading on the host, where `mknod` is not needed to *read* one. ### Copy backend — today, and after the upgrade `--bind` the base's full copy, nothing else: - `Open`: a **real, full copy** of the base rootfs into the staging directory. - `Step(n)`: the same writable tree for every step; `Root` sets `RootFS` and `WritableRoot`. - `Commit`: the staging directory *is* the rootfs; the commit is the `rename`. - `CachesSteps`: false. **Warning, which belongs in the code as well as here:** `store.copyTree` ([load.go:290](../../internal/store/load.go)) deliberately creates **hardlinks**, so that a second tag of one image costs no data — and there both copies are read-only. It is unusable for a build tree: a `RUN` step that writes in place through such a hardlink would modify the base image. The build needs a real copy. **The copy backend stays after the trixie upgrade.** Three reasons: 1. It is the fallback `doctor` selects when bwrap is older than 0.9 or installed setuid (a real condition in bwrap's own NEWS, not a hypothetical). werkdock has one target platform it does not control the package version of. `doctor` probes the writable root bind and names the backend `build` will use, so the two never disagree. 2. A single-shot build — a build environment rebuilt once a quarter — gains nothing from a per-step cache and pays nothing extra for the copy backend. The overlay backend's commit has to walk the upper directories and apply them; the copy backend's commit is a `rename`. 3. It is the backend the correctness tests run against everywhere, including hosts where overlay is unavailable, which is what keeps the test suite honest about the common path. ### Overlay backend — after the upgrade - `Open`: remembers the base; creates no copy. - `Step(n)`: `--overlay-src base --overlay-src up0 … --overlay-src up(n-1) --overlay up(n) work(n) /`. Stacking verified. - `Commit`: one copy of the base, then the upper directories applied in order, a character device 0:0 read as a deletion. `layers.go` already does this for OCI `.wh.` whiteouts; the mapping is one line of translation. - `CachesSteps`: true. The upper directory of a step, keyed by the base's identity plus the step's own text and the digest of what a `COPY` brought in, *is* the cache entry. It falls out of the mechanism rather than being built on top of it. - `Close`: `chmod` the `work/` directory (mode `d---------`, measured) before removing it, or the removal fails. **This RFC does not implement the overlay backend.** It is not available on the target until the upgrade, and an implementation that cannot be run against the target is an implementation that is not known to work. What this RFC does is cut the interface so that it fits, and record the measurements that say it will. ### The per-step cache comes with the overlay backend, not before A cache has to keep each cached step's result. With a union mount that result is the upper directory: a diff. Without one it is a whole tree — a cached four-step build would keep four full copies of a 2 GiB image to save a `RUN apt-get install`. That trade is wrong on a quota'd webspace, so: **no build cache in the copy backend.** `build` says so in its output rather than silently rebuilding what a user may believe is cached. ## The Two uid Levers **Decision: werkdock sets both itself, in front of every `RUN` step. The Dockerfile does not have to know it is being built by werkdock.** - `FAKEROOTDONTTRYCHOWN=1` is set in the step's environment, always. It is inert when nothing preloads libfakeroot. - The `RUN` command is wrapped in `fakeroot --` when the work tree has a `fakeroot` binary. When it does not, the step runs unwrapped and `build` says once, up front, which packages this will break — with the measured failure (`chown: ... Invalid argument`, dpkg exit 100) named, so the message is recognizable in a log. - `APT::Sandbox::User "root"` is written as `/etc/apt/apt.conf.d/00werkdock-sandbox` into the work tree before the steps and removed again at commit. It is how a Debian system expresses this, so a `RUN` step that reads apt's configuration sees a consistent one. Why werkdock and not the Dockerfile: the point of a Dockerfile is that the same file builds the same image under docker and here (RFC 0002's compatibility goal). A Dockerfile carrying `RUN fakeroot apt-get -o APT::Sandbox::User=root install …` is a werkdock-only Dockerfile, which defeats the purpose and rots the moment the levers change. Both levers are also harmless under docker: `FAKEROOTDONTTRYCHOWN` without fakeroot does nothing, and forcing apt's download user to root in a container that is already root is what most CI images do anyway. Two things follow: - **`fakeroot` must be in the base image.** Binding the *host's* `fakeroot` into the sandbox was considered and rejected: it needs `/usr/bin/fakeroot`, `faked`, and `libfakeroot-sysv.so` to match the image's glibc, and host and image are not the same distribution release by design. A one-line `apt-get install fakeroot` in the base image is the honest price. - **The check belongs in `build`, not in `doctor`.** `doctor` checks *the host*; whether an *image* carries fakeroot is not a property of the host. `build` reports it per build, naming the base image. A later `werkdock doctor IMAGE` that checks an image's build prerequisites is a reasonable verb — named here as a follow-up, not decided. **This whole section is version-independent.** The single-uid map is what the Hostsharing contract gives, and bwrap 0.12 does not change it. Overlay changes how the tree is made; it does not add a uid. ## What "No Layers" Means The README's "there are no layers, and nothing is shared between images" is one sentence doing three jobs, and it has been used as a reason for more than it supports. The three meanings, separated: ### 1. Layers as a mount — the only real obstacle, and a temporary one A layered filesystem needs a union mount. The sandbox cannot make one: `CapEff` is 0, `mount` returns `EPERM` (measured). So every mount has to be in bwrap's argv, and bwrap 0.8 — what the webspaces run today, per the README — has no overlay options. That is the whole of the obstacle. It expires with the trixie upgrade. bwrap 0.11 does overlay unprivileged, `--overlay-src` stacks, and the upper directory is a usable layer diff (all measured above). `fuse-overlayfs` is the alternative that would work on bwrap 0.8. It is unlikely on this target: it needs `/dev/fuse` and the setuid helper `fusermount3`, neither of which an unprivileged user on a managed webspace can count on. The right form for that uncertainty is a `doctor` probe, not an assumption in an RFC — which is also why this RFC does not plan for it. ### 2. Layers as storage dedup — needs no kernel feature at all This one never needed a mount. werkdock already hardlinks: the second and further `RepoTags` of one loaded image get a hardlinked copy ([load.go:290](../../internal/store/load.go)), so a second tag costs no data. Measured, that is 0.07 s versus 0.31 s for the same 129 MiB tree. Extending it from "two tags of one image" to "two versions of one base" — the ostree principle the README names as an option — needs content addressing in the store and nothing from bwrap. It is available today and after the upgrade alike. It is also not what `build` needs, so it is not decided here. ### 3. Layers as a transport and cache format — a decision, not a limit Keeping layer blobs with their digests would let `pull` reuse what it already has, let `save` write a genuinely multi-layer image, and let `build` cache per step. `load` throws them away today: `flattenImage` applies each layer and keeps none. That is a decision, taken for a reason (RFC 0004: "the gain is that bwrap needs nothing but a directory"), and it is the one with the highest running cost. Every `pull` of a new tag of the same base re-fetches and re-unpacks everything, including the 90% that did not change. Also not decided here — but named, because it is the one of the three that is costing something on every build today. ### What still argues for flat trees after the upgrade - A flat tree is bound read-only into every `run` at **zero** cost, and ten parallel runs share it. An overlay costs a mount per run and leaves a work directory behind that has to be cleaned up (and `chmod`ed first, measured). - A flat tree needs no feature probe, so it is the same on every host werkdock has to work on. - `run` is the hot path; `build` and `pull` are not. **Layers buy disk space and build cache. They do not buy run-time.** That is why this RFC puts a union mount only where it pays — behind `build.Backend`, for the build — and leaves the image model flat. ## Costs, Honestly - **One full tree copy per build.** Measured: 0.31 s for 129 MiB over 5448 inodes within ext4 on a local SSD with a warm cache, and 6.8 s for a whole build over a 143 MB base with an apt install in it. A fat JDK+Go+Node build image is ~2 GiB (README, Disk Footprint) with an order of magnitude more inodes, on storage that is shared and not under the caller's control. Extrapolating gives seconds; experience with that class of storage says to budget tens of seconds and not be surprised by a minute. **That is an estimate, not a measurement** — the measurements are the two figures above. - **Peak disk is base + work tree**, i.e. roughly twice the image, until the commit's `rename`; afterwards base + result, since a rebuilt tag does not replace the base. `doctor`'s 5 GiB headroom covers one 2 GiB build, not two at once. - **Quota, not just free space.** `doctor` already checks the group quota (`internal/doctor`); a build is exactly the workload that hits it. - **Every build starts at `FROM`.** With no per-step cache in the copy backend, changing the last line of a Dockerfile re-runs all of it, network `RUN` steps included. This is the cost the overlay backend removes, and the reason it is worth building after the upgrade rather than never. The README's *Disk Footprint* section is updated with this, and with which of the three meanings of "layer" its bullet about overlayfs is actually about. ## Instruction Scope, in Stages **Stage 1** — what makes a real build possible: `FROM`, `RUN`, `COPY`, plus the metadata instructions `store.ApplyChange` already handles (`ENTRYPOINT`, `CMD`, `ENV`, `WORKDIR`, `USER`, `VOLUME`, `LABEL`). Parser: line continuations with `\`, `#` comments, blank lines. CLI: `werkdock build [-f Dockerfile] [-t IMAGE] CONTEXT`. **Stage 2** — `ARG` with `${}` expansion, `SHELL`, `ADD` (local paths and archive extraction; a URL fetch is a separate question), `.dockerignore`. **Stage 3** — multi-stage: `FROM … AS name`, `COPY --from=name`. Cheap with flat trees: each stage is a staging directory of its own, and `COPY --from` is a copy between two host directories. No stage graph, no cache coordination — the thing that makes multi-stage expensive in a layered builder is absent here. ## What Is Rejected, and Why - **`RUN --mount=type=cache` and the rest of BuildKit** (`--mount=type=bind,from=…`, secrets, SSH forwarding, heredocs, parallel stage execution). BuildKit is a daemon with a content-addressed cache and its own frontend protocol. A `--mount` in a `RUN` step is a mount the sandbox cannot make for itself (`CapEff` 0, measured) and would have to be hoisted into bwrap's argv by the builder, which is possible but is a new surface with no consumer asking for it. Refused loudly, not ignored. - **`HEALTHCHECK`, `STOPSIGNAL`, `ONBUILD`.** None describes how to *build* an image: `HEALTHCHECK` needs a daemon that polls, `STOPSIGNAL` a lifecycle that reads it (werkdock's `stop` sends SIGTERM, RFC 0005), `ONBUILD` a trigger mechanism in the image config. Parsed-and-refused, so a Dockerfile using them fails with the reason instead of building something subtly different. - **`EXPOSE`.** `store.ApplyChange` already refuses it ("has no network to speak of"); `build` keeps that answer, because nothing about builds changed the network contract. - **`mknod` in a `RUN` step.** `EPERM`, measured. Device nodes are not image content here; `/dev` comes from the host (RFC 0002), and `layers.go` already skips device entries when unpacking. - **`mount` in a `RUN` step.** `EPERM`, measured, because `CapEff` is 0. Any mount a build step needs must be in bwrap's argv, which means it must be a `build` flag, which means it is not this. ## Considered Alternatives - **Build only on the overlay backend, after the upgrade.** The cleanest code and the longest wait: the problem — no builder on the webspaces — exists now, and the upgrade date is not werkdock's to set. With the interface cut as above, the copy backend is not a detour; it is the fallback that stays. - **Only the copy backend, ever.** Would make every build start at `FROM` forever and leave the measured overlay gain unused. The interface costs three small methods. - **`werkdock commit` from an instance** instead of a builder (RFC 0004: "not planned: werkdock instances are ephemeral by design"). It would need a writable instance rootfs, which is the same work as the copy backend, and gives an imperative, unrepeatable image instead of a Dockerfile. - **Keep building elsewhere and improve the upload path** (`save`/`load` over `scp`). Honest and cheap, and it stays available. It does not remove the second machine, and it makes the build environment a thing only one person can rebuild. - **`fakeroot` bound in from the host.** Rejected above: glibc skew between the host release and the image release. - **`APT_CONFIG` pointing at a bound-in file** instead of a fragment in the tree. Measured to work (`apt-get update` clean, exit 0). Rejected because the bind leaves a mountpoint in the tree anyway (measured) *and* puts a variable in the step's environment that a non-apt tool might read; the fragment is removed at commit and is what a Debian system expects. - **Shelling out to `buildah`/`kaniko`/`buildkit` as `pull` shells out to `crane`.** None of them runs under this contract: buildah needs user-namespace *ranges* (`newuidmap`, several uids) for its own unshare, and kaniko/buildkit assume they own the root filesystem of the container they run in. The one-uid map is the hard constraint, and it is the same constraint after the upgrade. ## Decision Outcome Pending — stage 1 on the copy backend is implemented under this RFC, with the interface cut for the overlay backend and the measurements above as the evidence that it fits. The overlay backend and the per-step cache follow when the target runs bwrap ≥ 0.9 and `doctor` reports it usable. ## Follow-Up Questions, Not Decided Here 1. **Store dedup between image versions** (meaning 2 above, the ostree principle): content-addressed files plus hardlinks. Needs nothing from bwrap, available today, pays for itself on a quota'd webspace. Its own RFC. 2. **Keeping layer blobs as a transport and cache format** (meaning 3): what `pull` would reuse, what `save` would write, and what it costs in store complexity. The highest running cost of the three. Its own RFC, and the one most likely to change `load`. 3. **`werkdock doctor IMAGE`** — checking an image's build prerequisites (`fakeroot`, a working apt, a shell) the way `doctor` checks the host's. 4. **`ADD` from a URL**, which is a network fetch inside a build and therefore a question about reproducibility, not about bwrap.