WendyOS Stagefiles: Fast Builds Without Hand-Tuning

In our local arm64 benchmark, wendy build with a Stagefile completed a warm cached build in 0.60 seconds versus 1.18
seconds for docker build, and rebuilt after an OS dependency edit in 2.73 seconds versus 6.86 seconds. The Stagefile
compiler can cache APT and pip as independent branches because it knows what each declaration means. Dockerfiles and
Containerfiles remain supported.
Faster builds need more structure
A Dockerfile is one of the most important developer tools of the last decade. It is also a shell script with an image at the end.
That freedom is useful. It lets a build do almost anything. The problem is that "almost anything" leaves the build system unable to distinguish intent from implementation. When it sees a RUN line, it does not know whether the command installs a runtime dependency, compiles a temporary build tool, downloads a model, or works around a board-specific CUDA problem. It only knows that a string of shell changed the filesystem.
For an edge app, the knowledge hidden in those strings piles up quickly:
- Which base-image tag is safe, and whether it still points to the same bytes
- The correct package-manager flags and cache mounts
- Which files should enter the build context and which secrets must stay out
- Whether the process can run without root
- Which CUDA wheels actually contain kernels for the target Jetson
- Which dependencies belong in a build layer and which belong in the final image
The result is usually a Dockerfile that began as six understandable lines and grew into local folklore. It works because one person remembers why every flag exists. Then a new JetPack release arrives, somebody copies the file into a second project, and the archaeology begins.
Stagefiles take a different position: the project should describe what it needs, and the toolchain should own how to build it correctly and quickly. When Wendy knows that one declaration is an APT dependency and another is a pip dependency, it does not have to put them into one linear invalidation chain. It can build them as independent branches and join their outputs at the end.
That sounds good in a diagram. We wanted to know whether it made a difference on a stopwatch.
Stagefile versus Dockerfile: the benchmark
We built the same small Python health API two ways for linux/arm64. Both versions used python:3.11-slim, installed curl through APT and debugpy==1.8.16 through pip, copied the same app.py, ran as UID 65532, and exported the image into the local Docker engine.
The Dockerfile was not intentionally bad. It copied requirements.txt before source code, used locked BuildKit cache mounts for APT and pip, installed packages with --no-install-recommends, named app.py explicitly, and used exec-form CMD. It is the kind of Dockerfile we would approve in review.
The commands under test were:
time wendy build --builder docker --dockerfile build.stagefile.yamltime docker build --platform linux/arm64 --load -t dockerfile-bench:latest .Select a scenario below. The cache map explains the timing better than the bars alone.
Fresh, isolated builders. Both pulled the same Python base image and built every layer.
| Scenario | Wendy Stagefile | Hand-Tuned Dockerfile | Difference |
|---|---|---|---|
| Cold, isolated builder | 15.92 s | 20.37 s | Stagefile 22% faster |
| Warm cache, no changes | 0.60 s | 1.18 s | Stagefile 49% faster |
Edit only app.py | 0.69 s | 1.21 s | Stagefile 43% faster |
| Add one APT dependency | 2.73 s | 6.86 s | Stagefile 60% faster |
The cold numbers are single first runs because each side started with its own empty docker-container builder and pulled its own base image. Network conditions therefore matter more in that row. The warm numbers are medians of three consecutive builds: Stagefile ran in 0.66, 0.60, and 0.53 seconds; Dockerfile ran in 1.15, 1.18, and 1.38 seconds. The edit rows are the first rebuild immediately after each change.
We ran the test on an Apple M4 Max MacBook Pro with 48 GB of memory, Wendy CLI 2026.08.18-022337, Docker Engine 29.4.0, the BuildKit container driver, and a linux/arm64 target. These numbers are not a universal promise; registries, disks, dependencies, and builders differ. The cache behavior is the transferable result.
Why the dependency edit is the interesting row
In the conventional Dockerfile, APT comes before pip. Change the APT instruction and every later layer becomes invalid, so pip installs again even though requirements.txt did not move. Its cache mount saves the wheel download, but Python still resolves and installs the package, then Docker exports the rebuilt layers.
Stagefile compiles pip into a separate dependency stage rooted directly on the pinned base image. The APT branch can change while the pip branch remains cached. A linked copy joins the pip filesystem overlay into the final stage. That is why the Stagefile log showed pip install as CACHED after adding ca-certificates, while the Dockerfile ran pip again.
You can hand-author the same graph in a multi-stage Dockerfile. Stagefile's advantage is that every project gets it automatically and keeps it as the compiler improves. Nobody has to remember the layout, cache IDs, copy order, or overlay path.
The optimized Dockerfile was the generous case
The Dockerfile above was hand-tuned: correct copy order, locked cache mounts, --no-install-recommends. Most projects do not start there. They start with a direct translation of the app's needs into the first thing that builds. Here is the same health API written the way a Dockerfile usually gets written the first time:
FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y curl
RUN pip install -r requirements.txt
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD python app.pyNothing here is wrong. It builds, it runs, and it would pass a quick review on a small project. But three ordinary habits decide its cache behavior: COPY . . before installing anything, no cache mounts, and APT then pip in a single linear chain. Compare it to the Dockerfile Wendy compiles from the same Stagefile — no one writes or maintains this; version: 1 and a few typed fields produce it:
FROM --platform=linux/arm64 python:3.11-slim@sha256:9c900dea9e8fb7e16277c179b555cc72d29a352dbc33cff48ad5a0412fd5bfc7 AS stagefile-pip-deps-0
WORKDIR /app
RUN --mount=type=cache,sharing=locked,id=stagefile-apt-lists,target=/var/lib/apt/lists \
--mount=type=cache,sharing=locked,id=stagefile-apt-archives,target=/var/cache/apt \
command -v pip >/dev/null 2>&1 || (apt-get update && apt-get install -y --no-install-recommends python3-pip)
COPY requirements.txt requirements.txt
RUN --mount=type=cache,sharing=locked,id=stagefile-pip,target=/root/.cache/pip \
pip install --root /opt/stagefile/pip/root -r requirements.txt
FROM --platform=linux/arm64 python:3.11-slim@sha256:9c900dea9e8fb7e16277c179b555cc72d29a352dbc33cff48ad5a0412fd5bfc7 AS app
WORKDIR /app
RUN --mount=type=cache,sharing=locked,id=stagefile-apt-lists,target=/var/lib/apt/lists \
--mount=type=cache,sharing=locked,id=stagefile-apt-archives,target=/var/cache/apt \
apt-get update && apt-get install -y --no-install-recommends curl
COPY --link --from=stagefile-pip-deps-0 /opt/stagefile/pip/root/ /
COPY app.py app.py
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
CMD ["python", "app.py"]
USER 65532Built the same way — docker buildx on a fresh docker-container builder, linux/arm64, warm rows the median of three consecutive builds — the two Dockerfiles diverge on exactly the operations you repeat all day:
| Scenario | Auto-generated from Stagefile | The usual first draft |
|---|---|---|
| Warm cache, no changes | 0.55 s | 1.11 s |
Edit only app.py | 0.96 s | 32.2 s |
| Add one APT dependency | 0.56 s | 32.2 s |
The first-draft column is not a typo. COPY . . copies the whole project in one layer, so editing a single line of app.py invalidates that layer and every layer after it. Both RUN lines run again, and because neither has a cache mount, apt-get update re-fetches the package lists and pip re-downloads debugpy from scratch. A one-character source change pays for a full dependency reinstall. Adding an APT package costs the same, for the same reason.
The auto-generated Dockerfile is the multi-stage graph from the previous section — which is why its APT-dependency rebuild stays sub-second where even the earlier hand-written single-stage Dockerfile needed 6.86 s. pip lives in its own stage rooted on the pinned base image and joins the final image with COPY --link; APT and pip carry locked cache mounts; app.py is copied last. Editing source touches only the final COPY, and adding an APT package leaves the pip stage CACHED.
Absolute rebuild times track your network, since the first draft's penalty is mostly re-downloading packages; the transferable result is the gap between the columns. Cold first builds are dominated by the base-image pull both versions share, so they are left out here. The point is not that the first draft is written by careless people. It is that getting Dockerfile cache behavior right is a specialized skill the build system can simply own instead.
What about upload speed?
wendy build does not upload an image to a device—that happens during wendy run—so we measured the upload both commands actually share: transferring the build context into BuildKit.
We placed a 128 MB training-data.bin beside the benchmark app without referencing it. With explicit COPY app.py, modern BuildKit was smart enough to send only the files the Dockerfile used. Both the optimized Dockerfile and Stagefile transferred 63 bytes in less than 0.1 seconds. That is an honest tie.
Then we changed the Dockerfile to the common COPY . . pattern. Stagefile continued deriving an allowlist from copy.from: local and transferred 63 bytes. The Dockerfile transferred 134.25 MB in 0.5 seconds on the local machine, copied it into the image, and increased the total warm build from 1.08 to 3.96 seconds. A remote builder or slower link makes that accidental upload much more visible.
Modern BuildKit already avoids unused files when a Dockerfile names explicit COPY paths, so the optimized Dockerfile ties the Stagefile. The difference appears when a Dockerfile copies the whole repository or its .dockerignore drifts. Stagefile derives the allowlist automatically.
The lesson is not that Dockerfiles cannot upload small contexts. They can, either with explicit COPY paths or a maintained .dockerignore. Stagefile makes the narrow context the default and derives it from the same declarations that already say what enters the image, so the ignore list cannot quietly drift away from the build.
Device upload is a separate measurement. The images produced in this test were 196.9 MB for the Stagefile and 207.6 MB for the Dockerfile when inspected locally, about 5.1% smaller for the Stagefile result. That suggests less data to package and potentially transfer, but it is not a device-upload benchmark: registries and Wendy transfer compressed layers, and a warm device may already have some of those layers. A fair end-to-end comparison would time wendy run against an equivalent deployment path on the same device, once cold and again with its layer cache warm.
Build one with us
The canonical filename is build.stagefile.yaml. It has a version and one or more named stages. That is enough structure for Wendy to understand the build before it executes anything.
The scene below builds a small Python health API in four decisions. Let it play, or click any step to inspect the file at that point.
1version: 12stages:3- name: app4from: python:3.11-slim5workdir: /app
Start with one named stage, a base image, and a working directory.
The complete file is still ordinary YAML:
version: 1
stages:
- name: app
from: python:3.11-slim
workdir: /app
install:
apt:
packages: [curl]
pip:
- requirements: requirements.txt
copy:
- from: local
paths: [app.py]
healthcheck:
exec:
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
interval: 30s
timeout: 3s
retries: 3
cmd: [python, app.py]There is deliberately no RUN field. Package installation, downloads, language builds, copies, health checks, entrypoints, and commands are typed operations with known inputs. That constraint is what lets Wendy validate and improve them.
Put the file next to app.py and requirements.txt, connect a WendyOS device, and run:
wendy runThere is no separate Stagefile command to learn. wendy run detects the file, builds for the selected device, deploys the image, starts the app, and streams its output. The same file also works with wendy build when you only want the image.
What happens during wendy build
A short source file does not mean the important work disappeared. It means the work moved into a compiler where it can be tested once and reused by every project.
Step through the pipeline. Each stage exposes the artifact or decision it adds.
Wendy detects build.stagefile.yaml and parses its typed fields.
version: 1
stages:
- name: app
from: python:3.11-slimwendy run uses this same build path, then transfers the image to the selected device, starts the app, and streams its logs. Keeping the build phase explicit is useful when you are tuning iteration time: wendy build lets you measure the image work without mixing in device transfer or process startup.
The first run creates build.stagefile.lock.yaml. The Stagefile keeps readable image tags such as python:3.11-slim; the lockfile records the exact registry digest that tag resolved to. Later builds reuse the pin. If the tag moves tomorrow, your image does not move with it.
The same rule applies to a declared download without its own checksum: Wendy hashes the bytes once and records the result. CUDA profiles are locked per target GPU architecture too, because upgrading a CLI should not silently swap the runtime underneath a deployed model.
Check the lockfile into source control. It is the build's receipt: a reviewable record of the mutable inputs Wendy froze on your behalf.
The useful part is what you did not have to write
Declarative build formats are sometimes dismissed as shorter syntax for a Dockerfile. Brevity is pleasant, but it is not the point. The point is that Wendy knows what every field means.
Select each line below. The left side is project intent; the right side is one piece of build machinery Wendy can derive because that intent is explicit.
from: python:3.11-slim
FROM python:3.11-slim@sha256:90744cff…
The readable tag stays in source control. Its digest is resolved into the lockfile, so the same Stagefile cannot silently pick up different base bytes tomorrow.
Those generated details are not a public interface you need to maintain. Wendy is free to improve the recipe as BuildKit, package managers, and device targets evolve while the project keeps saying the same stable thing.
That semantic knowledge also improves build speed. Python packages can be resolved in an independent dependency stage and promoted into the app with linked copies, so changing an OS package does not automatically invalidate several gigabytes of unrelated Python or CUDA dependencies. Apt, pip, npm, Swift, Rust, and Go builds get cache behavior appropriate to the ecosystem instead of whatever incantation happened to be copied into a project.
cuda: true replaces a page of board trivia
GPU containers are where this model becomes most obvious. Installing onnxruntime-gpu on a Jetson is not the same job as installing it on a desktop. The correct wheel index, CUDA runtime packages, library collection path, loader precedence, and runtime user all depend on the target architecture.
A Stagefile expresses the part the app actually knows:
version: 1
stages:
- name: app
from: ubuntu:22.04
workdir: /app
cuda: true
install:
apt:
packages: [python3-pip, python3-dev, libopenblas-dev, libgomp1]
pip:
- packages: [onnxruntime-gpu]
cuda: true
- packages: [onnx, 'numpy==1.26.4']
copy:
- from: local
paths: [app.py]
cmd: [python3, app.py]cuda: true says this stage needs the target's supported GPU stack. The nested cuda: true says that package group must come from the matching GPU wheel index. Wendy reads the device architecture, resolves a known profile, and pins that profile in the lockfile. If the target has no tested profile, the build stops with a useful error instead of guessing and letting the first GPU operation fail on the robot.
The difference matters. A container that builds successfully can still carry wheels with no kernels for its Jetson. Stagefiles move that failure from a device in the field to validation on the developer machine.
Stages are build stages, not environments
The name is literal: each item under stages is a container build stage. It does not mean staging versus production. A stage can compile an artifact, and a later stage can copy only that artifact into a smaller runtime image.
version: 1
stages:
- name: deps
from: python:3.12-slim
install:
apt:
packages: [build-essential]
pip:
- requirements: requirements.txt
- name: app
from: python:3.12-slim
workdir: /app
copy:
- from: deps
paths: [/usr/local/lib/python3.12/site-packages]
- from: local
paths: [app.py]
entrypoint:
exec: [python3, app.py]The final image gets the installed Python packages but not the compiler that produced them. Because from, copy, and install are structured, Wendy can verify that referenced stages exist, paths are safe, and the final process uses exec-form arguments.
Why there is no escape hatch inside the file
Stagefiles do not include arbitrary shell commands on purpose. The moment a format accepts run: curl ... | sh, the compiler loses the facts that make digest locking, input validation, cache planning, and safe quoting possible.
This does not make Stagefiles a replacement for every Dockerfile. If your build genuinely needs an operation the format cannot describe, keep using a Dockerfile or Containerfile; Wendy auto-detects those too. The boundary is simple:
- Use a Stagefile when your app fits the supported, typed operations and you want secure defaults with less maintenance.
- Use a Dockerfile when the build needs unrestricted shell behavior or a tool the Stagefile compiler does not yet model.
We would rather have an honest escape hatch at the build-file level than a hidden one that weakens every Stagefile.
Start with the file you already have
You do not need to redesign the application. Translate the Dockerfile one concern at a time: base image, dependencies, local files, build step, runtime process, then health check. Run wendy build after each move. The progressive example at the top is not just an explanation; it is the migration strategy.
The quickest place to begin is the WendyOS examples on GitHub, which include Python, GPU, ONNX, ROS 2, Swift, Rust, Go, web, and multi-service Stagefiles. Install or update the Wendy CLI, drop build.stagefile.yaml into a project, and run it on the device you already use.
A Stagefile is small because it leaves out decisions your app should never have owned. You describe the software. Wendy carries the container knowledge forward.
Related post
Expand your knowledge with these hand-picked posts.

JEPA Explained: Why Robot AI Predicts Features, Not Pixels
JEPA is the architecture behind Meta's V-JEPA models, VL-JEPA, and a growing slice of robot learning. This guide explains it for developers coming from mobile and frontend, with interactive scenes showing why predicting features beats predicting pixels, how the architecture works, and how robots use it to plan.
Wendy Labs - Wendy Labs Team

Inverse Kinematics Explained: Drag a Robot Arm With and Without It
Inverse kinematics turns "put the gripper here" into joint angles. Learn what IK is by driving a 3D robot arm two ways, first joint by joint and then with a solver, and get a tour of the techniques robots use today, from Jacobian methods to CCD, FABRIK, and TRAC-IK.
Wendy Labs - Wendy Labs Team


Ready to build on WendyOS?
WendyOS is the open-source operating system for Physical AI — deploy your apps to NVIDIA Jetson, Raspberry Pi, and more in seconds, over USB-C, wireless, or the cloud.