next-zonesv0.1.0

Zones: live installs

Zones is a Next production server that starts with the shell. It loads zone images from a store while it runs, with no restart.

Running it#

On one machine (development, or a server that builds), in the workspace's folder (see build and start):

next-zones build                                            # the shell, every zone as an image, zones.json pins
next-zones start --port 3000
next-zones build blog && next-zones install blog 12         # a new version of one zone: install, swap or roll back, live

With images built elsewhere (CI), published to a source, and pulled by the server (see zone images):

# CI
next-zones build blog --pack          # .zones-images/blog/<version>.tgz: upload it where the source points
# the server
next-zones serve --shell ./shell --store /srv/zones --source "https://images.example.com/{zone}/{version}.tgz"
next-zones pull blog 12 --store /srv/zones --source "https://images.example.com/{zone}/{version}.tgz"   # on the server: into the store
next-zones install blog 12 --url http://127.0.0.1:3000      # the running Zones installs it

next-zones pull runs beside a serving Zones: it writes a new folder of the store, and the running Zones reads it when asked to install. A zone that declares livePull: true may skip that step: install --url of a version the store lacks then asks the running Zones to pull it from its own sources first. Without livePull, that install is refused, and says the image must be pulled first.

Or from code:

const { createZones } = require("@runsnip/next-zones/zones");

const zones = createZones({
  shell: "./shell",                           // the shell's folder, after next-zones build
  store: process.env.NEXT_ZONES_STORE,        // <store>/<zone>/<version>/, built with next-zones build
  pins: { blog: "12", shop: "4" },             // installed at boot, under the store's state.json
  adminToken: process.env.NEXT_ZONES_ADMIN_TOKEN,
});
await zones.listen(3000);
await zones.install("blog", "13");

Rules for the process:

// server.mjs
import http from "node:http";
import { createZones } from "@runsnip/next-zones/zones";   // before anything imports Next
const zones = createZones({ shell: "./shell", store: "/srv/zones", adminToken: process.env.NEXT_ZONES_ADMIN_TOKEN });
await zones.prepare(3000, "0.0.0.0");
http.createServer((req, res) => zones.handleRequest(req, res).catch(() => { res.statusCode = 500; res.end(); })).listen(3000, "0.0.0.0");

The store#

Endpoints#

Zones serves no URL of its own unless the project declares it, in the shell's next.config, under one base path (/_next-zones unless changed):

export default zoneConfig({
  mount: "/",
  endpoints: { base: "/_next-zones", events: true, health: true, admin: true },   // each group off unless set
});

createZones({ endpoints }) overrides the shell's declaration (false for none). The base may not be a path the shell serves, or a zone's mount.

One more URL needs no group: with the shell's metrics: true, GET <base>/metrics serves the metrics to an admin, whether endpoints is declared or not. endpoints: { admin: true } is not needed for it.

An admin is a request with Authorization: Bearer <token>, the token being createZones({ adminToken }) or, for the CLI and zones.js, NEXT_ZONES_ADMIN_TOKEN. When no token is configured, an admin is any request from the same machine (a loopback address).

Group Endpoint What it does
events GET <base>/events The swap events <ZoneUpdates /> listens to: only zone names and versions. Public
health GET <base>/health Public: 200 { ok, degraded } once Zones serves, for a supervisor such as Docker's HEALTHCHECK. A zone that failed at boot makes it degraded, not unhealthy: a restart would not fix its build. An admin also gets the active versions, the boot failures, uptime and RSS
admin (every endpoint of the group needs the admin token) GET <base>/images The zone images in the store, by zone: version, active or not, live pull, built with, integrity
GET <base>/images/<zone>/<version> One zone image
POST <base>/images/<zone>/<version>/pull Asks Zones to pull it from its sources into the store, without installing it: 200 { pulledFrom } (null when it was there); 409 with the reason when refused. A ping: only for a zone that allows live pulls
POST <base>/images/<zone>/<version>/install Installs, swaps to or rolls back to it, pulled first (as a ping) when the store lacks it: 200 with the timings; 409 with the reason when refused (nothing changes)
DELETE <base>/images/<zone>/<version> Removes it from the store. Never the active version; one whose code other versions still share is refused until a restart
POST <base>/prune?keep=2[&dry=1] Prunes the store: { removed, held, kept, freedBytes }. dry=1 only says what it would do
POST <base>/collect?keep=2 Collects old versions' loaded code and disk caches (not their images), keeping the active one and keep before it per zone
POST <base>/policy Replaces the instrumentation policy in the running Zones (not the file): the body is the JSON of zones.config.json, { "instrumentation": { … } }, and replaces the whole policy (what it leaves out is back to the default); 200 with the policy now in force

A zone image never travels over these endpoints. There is no upload: they ask Zones to pull, and Zones fetches the image from its own sources.

A request that is not an admin's gets 401 { error }, and nothing changes. A token never goes in next.config.

What happens on an install#

  1. Stage. Zones reads the zone build: its routes, server actions and prerendered pages. Then it checks the rules: the mount, the aliases, no zone-level proxy.ts, the same Next version.
  2. Prepare. Everything that grows with the size of the app is computed before the switch.
  3. Switch. One synchronous step, so no request ever sees half an install. It takes about 3 µs (median, an app of 248 routes, 75 of them dynamic, and a zone of 29 routes).

The heavy work of a first install (seeding the zone's cache, analysing its chunks) runs in worker threads, so the server keeps answering while it runs. Reinstalling a version, for a rollback, takes about 4 ms.

Under load, with 32 clients and 18 swaps, no request got a wrong answer.

A rollback is the same operation with the previous version. Installs can run at the same time: their heavy work runs in parallel, and their switches take turns.

What a user notices#

What is shared#

What works inside a zone#

All of App Router's routing (dynamic and catch-all routes, groups, parallel and intercepting routes, loading, error, not-found), route handlers and metadata routes, server actions, prerendering, ISR and generateStaticParams, revalidation across zones, next/font, next/image, public files, the shell's proxy.ts, the zone's routing rules, aliases, instrumentation. And the Pages Router: static pages, getStaticProps, getServerSideProps, its own _app and _document.

See What is supported for the full list.

Zone images#

A zone image is one version of a zone as next-zones build stores it: <store>/<zone>/<version>/, the build with zone.json. It holds only what serving needs (no build cache, traces or generated types): 18 MB for the test blog, 3.5 MB packed. A real app's image can weigh hundreds of MB. next-zones pack <zone image dir> packs one into a single .tgz, the form a source serves.

How an image enters the store: a pull#

An image enters a store only by being pulled: the server fetches it from a source, checks it, and moves it into the store in one rename. Nothing is uploaded to Zones. (A store can also be built into directly, with next-zones build --store, on a machine that builds.)

Who asks for a pull:

Live pulls#

A ping can make Zones pull only a zone that declares it: zoneConfig({ mount, livePull: true }), recorded in every image's zone.json.

The disk#

A pull leaves minFree bytes free on the store's disk (createZones({ minFree }), --min-free <MB>; 1 GiB by default).

Pruning#

Every pull adds an image, so a store left alone fills its disk. Pruning removes the images no longer needed, with their disk caches. For each zone with an active version, it keeps:

Every other version of that zone is removed. A zone with no active version is left alone. Folders a crashed pull left behind are removed too.

When it runs:

Memory. When a version is collected, its code is unloaded, and once Zones has been idle for a second it hands back what V8 still keeps of it (its compilation cache, the heap's grown pages) with a last-resort collection: tens of ms, at most once per collect, and never while a request is in flight unless 30 s have passed (createZones({ reclaim: { idleMs, maxWaitMs } }), or false). Over 120 versions of a test zone, RSS stays flat.

Pruning is distinct from collecting (POST <base>/collect): collecting frees what a running Zones holds for an old version (loaded code, disk caches) and leaves its image in the store; pruning removes images.

The numbers#

Pulling one zone image over HTTP (fromHttp into an empty folder), each run in a fresh process: a synthetic, build-shaped image (files of 4 KB to 2 MB, 60% random bytes, 40% repetitive JavaScript), median of 3 runs, Node 24.16, Apple M1. Peak RSS includes Node's own ~45 MB. Repeat it with node tools/bench/pull.mjs --mb 300 --against <git ref>.

Image Format Peak RSS Time
300 MB (176 MB packed) .tgz 155 MB 0.4 s
300 MB .zip (--format zip) 210 MB 0.6 s
600 MB .tgz 167 MB 0.8 s

Measured 2026-10-09. The 12 MB between 300 and 600 MB is garbage not yet collected, not the image: memory stays flat as images grow. The reader before streaming held the whole image in memory: 756 MB peak and 7.0 s for the 300 MB image, on the same machine when it was replaced.

Supported Next versions#

Zones works on Next's internals, so it runs only on the Next versions next-zones has been checked against (today 16.3.6, 16.3.7, 16.3.8 and 16.4.0; 16.3.8 fixes security issues in Next, among them SSRF in the image optimizer and cache poisoning: use 16.3.8 or 16.4.0). On any other version, or on a Next where a module or function it hooks has moved, createZones throws before anything is hooked, and lists every difference. That check is Zones' (mode "zones"); next-zones build, dev and mode "single" run Next itself, with no hook. They check nothing more than the package manager does with the peer range (next 16.3.6, 16.3.7, 16.3.8 or 16.4.0), which warns on another version; use one of those, since what they build is what Zones runs:

next-zones: this Next does not match what Zones relies on (next 16.5.0):
  - Next 16.5.0 has not been checked with next-zones (checked: 16.3.6, 16.3.7, 16.3.8, 16.4.0); run tools/upgrade-guard.mjs 16.5.0, …

createZones({ unsupportedNext: true }) runs anyway. It is meant for the upgrade guard, which runs the whole check suite on the new version before it is added.

Edit this page on GitHub · Markdown