Inside the node bypass: why cold builds got five times faster
Percher stopped using Nixpacks as the default for Node and Bun and wrote its own multi-stage Dockerfile generator. Cold builds went from a minute or two down to about twenty seconds. Here is the design and the trade-off.
If you deploy a Node or Bun app to Percher, the thing you notice first is that publishing got fast. A cold build, the kind you get on a brand new app or after changing dependencies, used to take 60 to 112 seconds. It now lands in about 19. A warm redeploy, where you only touched source, takes about 11. That is the difference between waiting for a build and barely noticing one, and it changes how often you are willing to ship.
This post is about how that happened. It is the technical one this month, so it gets into build systems. If you have opinions about Dockerfiles, read on.
What we used before, and where it cost us
For a long time Percher built every app with Nixpacks. Nixpacks is good at a hard problem: take an arbitrary repo in an unknown language and produce a working image with no configuration. It detects the runtime, picks the right base, and wires up the build. For a platform that has to accept whatever a user throws at it, that generality is the whole point.
The cost of that generality shows up when the runtime is not actually unknown. Most apps people deploy to Percher are Node or Bun. For those, Nixpacks still pays for a detection and provisioning step built to handle every case, and it produces a large image, close to a gigabyte. The build is correct. It is just slower and heavier than a single-runtime build needs to be.
So we kept Nixpacks for the long tail and wrote a purpose-built path for the common case.
The dispatch
Everything routes through one decision in build-stage.ts, keyed on the runtime field in your percher.toml:
nodeorbungo tonode-build.ts, which generates a multi-stage Dockerfile on an Alpine base.staticgoes tostatic-build.ts, a slim Caddy image that serves pre-built files.dockerbuilds your own Dockerfile, untouched.python, and anything without a bypass yet, falls through to Nixpacks.
Each generator writes a small .percher-* working directory inside the build context, drops in the Dockerfile and any assets it needs, and hands it to the Docker build path on the same worker service that already ran every build. The Nixpacks apps take a different endpoint on that worker, but the worker, the socket isolation, and the deploy plumbing are unchanged. Only the thing that produces the Dockerfile is new.
The deps layer
The single biggest win is boring, which is usually how it goes. We split dependency installation into its own stage so Docker can cache it:
```dockerfile FROM oven/bun:1-alpine AS deps WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile
FROM oven/bun:1-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN bun run build ```
When you redeploy after a source-only change, package.json and the lockfile are byte-for-byte identical, so the deps stage is a cache hit and bun install does not run again. That is the path to an 11-second warm build. Change a dependency and the cache key moves, so the install runs and you are back to a cold build. This is exactly the behaviour you want, and it is the reason the install step is isolated from your source.
Cache imprint
There is a subtle bug waiting in any setup like this. Percher keeps a content-addressed image cache so an unchanged app skips the build entirely. The hash that keys that cache has to include the generated Dockerfile bytes. If it does not, then changing how we generate the Dockerfile, a base image bump, a new build flag, would not invalidate the cache, and an app could keep getting served from a stale image built by the old generator. So the generated Dockerfile is folded into the imprint along with your source, your lockfile, and your config. Any input that changes the image changes the hash.
What you get, and what it costs
The output image lands around 225 MB, against close to a gigabyte for the Nixpacks equivalent. Smaller images pull faster and cost less to keep around. Combined with the build times, the iteration loop gets cheaper in a way you feel after the third or fourth deploy of an afternoon.
We did not delete Nixpacks. Python still uses it, and so does any runtime we have not written a bypass for. When Python traffic grows enough to justify it, a python-build.ts will sit next to node-build.ts and the dispatch will pick it up. Until then, the fallback is the honest answer for those apps.
The trade-off is ownership. We used to hand the build to a tool maintained by other people, and it kept itself current. Now we own the build logic for the most common runtimes, which means we own keeping the base images patched, the package-manager detection correct, and the cache invalidation honest. That is real work. It is worth it, because the build is on the critical path of every single deploy, and a few seconds there is something every user spends, every time.