Ship a new frontend to some of your users before all of them.
One URL, several builds side by side, and you decide who runs which.
Why · How it works · What you get · Quick start · A rollout · Client libraries · Admin UI · Documentation · Building from source
Many teams serve one web app to many customers: stores, tenants, branches, partners. The app grows old, a big upgrade gets built, and then comes the hard part. Shipping it means every user gets the new build at the same moment, on the same day, whether their backend is ready or not. Not shipping it means the upgrade waits, sometimes for years, because nobody wants to bet the whole user base on one deploy.
Bundle Pilot removes that bet. It keeps every build you still need, old and new, behind the same URL, and routes each visitor to one of them. A handful of stores run the new build this week. If they are happy, more stores follow. If something breaks, one edit sends them back to the old build, without a redeploy and without touching the other users. The rollout becomes a dial you turn, not a switch you flip.
The project was born from exactly that situation: a company whose clients all ran one aging frontend, an upgrade ready to go, and a team that was not willing to put 100% of the users on it on day one.
Every build is a zip in a folder or a bucket. Each time the app starts, it sends Bundle Pilot a few facts about the visitor: which backend it talks to, which store or user it is. Bundle Pilot answers with one build and the browser reloads once if it is running a different one. The answer is remembered in a cookie, so a user stays on the same build until you change the rules.
flowchart LR
U[Browser opens the app] --> F[App posts a few facts<br>backend, store, user]
F --> P{Bundle Pilot}
P -->|a rule matches| N[4.80.0<br>the pilot group]
P -->|backend table| B[4.79.0<br>fits that backend version]
P -->|otherwise| D[4.78.0<br>default]
N --> C[Cookie remembers the choice<br>the app reloads once if it differs]
B --> C
D --> C
Three things decide, in this order:
| Priority | Decision | What it does |
|---|---|---|
| 1 | Rules | The first rule whose conditions all match. Exact values, lists, version ranges, a stable percentage of users, with an optional expiry date. |
| 2 | Backend table | The newest build that fits the backend version the app reported, so a frontend never runs ahead of its API. |
| 3 | Default | What everyone else gets. |
| Capability | What it means |
|---|---|
| Builds side by side | Old and new versions serve at once; hashed files resolve across all of them, so a session that was open during a switch never breaks. |
| Targeting without code | Pick users by store, tenant, user id, app version, backend version, or a percentage that only grows as you raise it. |
| An admin UI | Upload zips, edit rules, test a decision before saving, watch what is being served, block a bad build in one click, restore any earlier config. Works on a phone as well as a desktop. |
| Safe for a team | Saves never overwrite a newer one, every save is kept with who made it, and CI gets scoped API tokens instead of the admin password. |
| Over-the-air aware | A build can require a minimum native app version; older apps keep the closest build they can run. |
| A first run that sets itself up | Project name, admin password, bundle source and default build in five steps; the admin API is then protected by HTTP Basic auth. |
| Bundles from a folder or a bucket | Point it at a directory, or at S3, Cloudflare R2, MinIO and the like, and it mirrors the zips by itself. |
| Signed bundles | Optionally, so only builds signed with your key can serve. |
| One small binary | No database: zips are served in place from the page cache, a few MB of RAM whatever the number of versions, roughly ten microseconds of CPU per request. Docker images for amd64 and arm64, binaries for macOS, Windows and Linux. |
Put each build in a zip named by its version, with index.html at the root
of the zip, and start the gateway on that folder:
mkdir -p dist/versions
cp 4.78.0.zip 4.80.0.zip dist/versions/
docker run -p 8080:8080 -v "$PWD/dist:/app/dist" ghcr.io/zenkiet/bundle-pilotOpen http://localhost:8080/__gateway/ui/. The first visit runs the setup
wizard, which asks for the setup token the gateway printed to its log
(docker logs); after that, the app itself is served at http://localhost:8080/ and
the newest build is the default until you say otherwise.
Prefer a binary? Download one from the
releases and run
DIST=./dist ./bundle-pilot. Everything on disk is the versions/ folder,
one config.pb written by the UI, and the history/ of its saves.
Then teach the app to ask. For Angular, one provider does it; for any other
framework, @bundle-pilot/core. See Client libraries.
- Upload the new build. Drop
4.80.0.zipon the Upload page, orPUTit to the API from CI. Nobody sees it yet: the default is still4.78.0. - Pick the pilot group. Add a rule: store
2020210gets4.80.0. Save. The next time that store opens the app, it reloads into the new build. - Widen. Change the rule to a list of stores, then to
10%of users, then50%. The percentage is stable per user, so raising it only adds people. - Finish or back out. Make
4.80.0the default and delete the rule, or, if something is wrong, block4.80.0from its menu: every rule pointing at it pauses at once and the pilot group is back on4.78.0at their next page. Unblock it once fixed, or restore an earlier config from History.
The same config, written as JSON:
{
"default": "4.78.0",
"rules": [
{ "id": "pilot-stores", "when": { "storeID": [2020210, 2020215] }, "bundle": "4.80.0" },
{ "id": "ten-percent", "when": { "user": "10%" }, "bundle": "4.80.0" }
]
}The app has to send its facts and reload when the answer changes. The
libs/ folder ships that logic as packages:
| Package | For | Status |
|---|---|---|
@bundle-pilot/angular |
Angular 18 to 22, one provideBundlePilot() call |
Ready |
@bundle-pilot/core |
Any framework or plain JavaScript, the same logic without Angular | Ready |
@bundle-pilot/capacitor |
Capacitor apps: signed over-the-air updates with Capgo | Ready |
| React and Vue adapters | Thin wrappers over core |
Planned |
// app.config.ts: every route now follows the gateway's pick for these facts
provideBundlePilot({
facts: () => {
const store = inject(StoreService);
return store.id ? { storeID: store.id, backend: store.posVersion } : null;
},
});Setup and options are in libs/README.md.
Overview shows what is being served and lets you change it. Upload takes zips, checks them and reloads the gateway. Settings holds what the setup wizard asked: project name, admin account, bundle source and default build. Every change goes through a review step before it is saved, and the decision tester shows what a visitor with given facts would get, without saving anything.
| Resource | Covers |
|---|---|
| docs/reference.md | The config contract, rule syntax, bundle sources, every endpoint, environment variables, signing and deployment checks. |
| libs/README.md | The client packages. |
proto/bundlepilot/config/v1/config.proto |
The config as a protobuf contract. |
Requires Go 1.27, Node 26 and pnpm.
make ui # build the admin UI into internal/ui/build/app
make build # bin/gateway, with the UI embedded
make gen # regenerate Go and TypeScript from proto/
make lint # golangci-lint, including layer rules
docker compose up --buildJenkins releases every vX.Y.Z tag at that one version: archives for macOS
(Intel and Apple silicon), Windows and Linux, a multi-arch image on
ghcr.io/zenkiet/bundle-pilot, and the npm packages; see the
Jenkinsfile.
Bug reports, feature requests and pull requests are welcome. Please open an issue before starting large changes so the design can be agreed first; every user-facing change in Bundle Pilot starts as a mockup.
Bundle Pilot is free software, released under the Apache-2.0.
© 2026 ZenSoftware. All rights reserved.
