From 8df6aab24db30b80bd11390066707f473f267aff Mon Sep 17 00:00:00 2001 From: ragul2105 Date: Mon, 28 Sep 2026 22:46:08 +0530 Subject: [PATCH 01/11] feat(vm): harden app containers and bound what they write at runtime An app on a server now runs read-only: only its two .next volumes and a 64 MB noexec /tmp are writable, every capability is dropped, no-new-privileges is set and it is limited to 512 processes. The December 2025 break-ins through CVE-2025-55182 on self-hosted Next.js rewrote next.config.js and lockfiles and ran miners from /tmp; none of that can persist now. e2e.sh checks it from inside a deployed container after it has regenerated a page and optimized an image. server add also installs a cache guard, run every ten minutes, that keeps each app's runtime writes under 10% of the disk (never under 1 GiB), deleting the least recently used first. Only files Next.js wrote at runtime are counted, never the build's own. Verified on a stand-in with a 12 GB disk, including evicting a live page and image: both were served, rendered again and cached again, also after a restart. Setup version 3. The full e2e suite passes on a local Ubuntu 24.04 stand-in, 21 of 21. The roadmap records the next phase of the server target in order. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 25 ++++ README.md | 12 +- conformance/vm/e2e.sh | 28 ++++- docs/design.md | 39 +++++++ docs/roadmap.md | 23 ++++ docs/vm.md | 16 ++- packages/cli/runtime/vm/cache-guard.sh | 108 ++++++++++++++++++ packages/cli/runtime/vm/setup.sh | 53 ++++++++- packages/cli/scripts/verify-pack.mjs | 2 +- packages/cli/src/server.ts | 13 ++- .../cli/src/targets/vm/cache-guard.test.ts | 85 ++++++++++++++ packages/cli/src/targets/vm/vm-target.test.ts | 16 +++ packages/cli/src/targets/vm/vm-target.ts | 17 +++ site/content/docs/vm.mdx | 9 +- 14 files changed, 432 insertions(+), 14 deletions(-) create mode 100644 packages/cli/runtime/vm/cache-guard.sh create mode 100644 packages/cli/src/targets/vm/cache-guard.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b5be91d..c9fea2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to this repository. Attribution rules: `AGENTS.md` §1.1. ## [Unreleased] +### Added + +- **A server keeps what each app writes at runtime under a limit.** `server add` installs + a cache guard, run every ten minutes, that keeps each app's optimized images, fetch + cache and runtime-rendered pages under 10% of the disk, never under 1 GiB, deleting + the least recently used first and logging it under `nextship-cache-guard`. Only what + Next.js wrote at runtime is ever deleted, never a file the build produced. A site + that moved off Vercel reported its ISR cache growing about 145 GB a day under + crawlers until the disk filled and the site went down; nothing in Next.js bounds it. + Setup version 3, so `server status` asks servers set up earlier to run `server add` + again. (Ragul D) + +### Changed + +- **An app on a server runs in a hardened container.** Its root filesystem is read-only + apart from the two `.next` volumes and a 64 MB `/tmp` that is emptied on restart and + cannot run a binary; every Linux capability is dropped, privileges cannot be gained, + and it is limited to 512 processes. The December 2025 break-ins through CVE-2025-55182 + on self-hosted Next.js rewrote `next.config.js` and lockfiles and ran miners from + `/tmp`; none of that can persist now. Measured with the streaming fixture: streaming, + on-demand ISR, the Edge route and image optimization all work, and nothing is written + outside the volumes, and `conformance/vm/e2e.sh` checks it on every run. An app that + writes files outside `.next` now fails with `EROFS`, which the README and `docs/vm.md` + §4 state. (Ragul D) + ### Docs - **The evidence page compares nextship with Coolify, measured.** The same app deployed diff --git a/README.md b/README.md index f724f95..16e9a34 100644 --- a/README.md +++ b/README.md @@ -698,7 +698,8 @@ server presenting any other key is refused. With `--yes` it installs Docker CE if the server has none, creates a `nextship` user and proves a login as that user from your machine, runs Caddy on ports 80 and 443, limits the journal, enables security updates and ufw, installs a watchdog for containers that stay -unhealthy, and turns off SSH password logins last. Every later command connects as +unhealthy and a cache guard that keeps what each app writes at runtime under a limit, and +turns off SSH password logins last. Every later command connects as `nextship`. Running it again against a server that is set up changes nothing; running it from a second project records the same server for that project. Each step is described in [`docs/vm.md`](./docs/vm.md). @@ -908,6 +909,8 @@ On a server, `server add` creates: caddy/sites/ one Caddy site per app /usr/local/lib/nextship/watchdog.sh restarts containers unhealthy for 3 checks in a row /etc/systemd/system/nextship-watchdog.* the timer that runs it every minute +/usr/local/lib/nextship/cache-guard.sh keeps each app's runtime cache under 10% of the disk +/etc/systemd/system/nextship-cache-guard.* the timer that runs it every ten minutes /etc/systemd/journald.conf.d/nextship.conf SystemMaxUse=500M, only if no limit was set /etc/ssh/sshd_config.d/10-nextship.conf unless --no-ssh-hardening /etc/apt/apt.conf.d/20auto-upgrades unless --no-auto-updates @@ -1001,6 +1004,11 @@ deploy: back a variable changed since, which App Platform does. - **On a server, only the first app deployed answers `http://`.** Every other app answers only on the domains attached to it. +- **On a server, your app cannot write its own files.** The container is read-only apart + from `.next` and a 64 MB `/tmp` that cannot run a binary, so an attacker who gets code + running through a flaw in your app can neither rewrite it nor leave a program behind. An + app that saves uploads to a local folder fails with `EROFS`, and a package that unpacks + an executable into `/tmp`, such as a headless Chromium, cannot run it. - **A server's env file cannot hold a value with a line break.** Docker reads it one line per variable, so `env push` refuses such a value rather than truncating it. Encode it, for example as base64. @@ -1083,7 +1091,7 @@ conformance/ scripts for the official Next.js adapter compatibility su packages/ adapter/ Next.js Adapter API implementation, injected via NEXT_ADAPTER_PATH cli/ every command; runtime/ holds the files copied into a build, - and runtime/vm/ the setup and watchdog scripts sent to a server + and runtime/vm/ the setup, watchdog and cache guard scripts sent to a server site/ the marketing site and documentation, exported as static files ``` diff --git a/conformance/vm/e2e.sh b/conformance/vm/e2e.sh index d888589..69a3ef9 100755 --- a/conformance/vm/e2e.sh +++ b/conformance/vm/e2e.sh @@ -6,7 +6,7 @@ # previous one serving, rollback returns to the previous deployment, env push takes effect # without downtime, a replaced deployment's logs are still readable, pruning keeps the # live image, a regenerated ISR page and an optimized image survive a container restart, -# a domain gets its own site, and destroying one app leaves another serving. +# the app's container is read-only and cannot run what it writes, a domain gets its own site, and destroying one app leaves another serving. # # It ends by destroying both apps it created, so a server it ran against is left with only # what `server add` set up. @@ -278,6 +278,32 @@ AFTER_CACHED="$(cache_state "${IMAGE_URL}")" fail "durability" "the optimized image was re-encoded after the restart rather than read from the cache: ${AFTER_CACHED:-no header}" check "durability" "the regenerated page and the optimized image both survived a container restart" +# ---------------------------------------------------------------- hardening + +# The same container has just regenerated a page and optimized an image, so what +# follows proves the protections are enforced, not only that the app tolerates them. +# Each is tried from inside, as code running through a flaw in the app would try it. +HARDENING="$(on_server "${DOCKER} inspect -f '{{.HostConfig.ReadonlyRootfs}} {{.HostConfig.CapDrop}} {{.HostConfig.SecurityOpt}} {{.HostConfig.PidsLimit}}' ${CONTAINER}")" +[ "${HARDENING}" = "true [ALL] [no-new-privileges] 512" ] || + fail "hardening" "the container is not run read-only with no capabilities: ${HARDENING}" +on_server "${DOCKER} exec ${CONTAINER} sh -c 'touch next.config.js' 2> /dev/null" && + fail "hardening" "code inside the container could rewrite the app" +on_server "${DOCKER} exec ${CONTAINER} sh -c 'cp /bin/true /tmp/dropped && /tmp/dropped' 2> /dev/null" && + fail "hardening" "code inside the container could run a program it wrote to /tmp" +WRITTEN="$(on_server "${DOCKER} diff ${CONTAINER}")" +[ -z "${WRITTEN}" ] || fail "hardening" "the container wrote outside its volumes: ${WRITTEN}" +check "hardening" "the app can rewrite none of its files, cannot run a program from /tmp, and wrote nothing outside its volumes" + +# ------------------------------------------------------------- cache guard + +# Run now rather than waited for, as the timer runs it. Under its limit it must +# delete nothing, so the regenerated page is still the one that survived the restart. +on_server "systemctl is-active --quiet nextship-cache-guard.timer" || fail "cache guard" "its timer is not active" +on_server "sudo -n systemctl start nextship-cache-guard.service 2> /dev/null || systemctl start nextship-cache-guard.service" || + fail "cache guard" "a run failed: $(on_server 'journalctl -u nextship-cache-guard.service -n 5 --no-pager' 2>&1)" +[ "$(curl -s "${URL}/isr")" = "${REGENERATED}" ] || fail "cache guard" "a run under the limit changed what the app serves" +check "cache guard" "its timer is active, and a run under the limit left the app's cache as it was" + # ------------------------------------------------------------------ domains nextship domain add e2e.nextship.test --yes diff --git a/docs/design.md b/docs/design.md index 6b848b3..358fed3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -686,6 +686,28 @@ would be owned by root. After a restart with both volumes, a page regenerated by `revalidatePath` kept its content, the optimized image stayed cached, and `docker diff` reported no writes to the container layer. +#### Cache guard (Implemented) + +Nothing in Next.js bounds what it writes while an app serves, and one site that moved to its +own server reported its ISR cache growing about 145 GB a day under crawlers until the disk +filled. `runtime/vm/cache-guard.sh`, installed by `server add` and run every ten minutes by a +systemd timer, keeps each app's runtime writes under 10% of the disk Docker stores volumes +on, and never under 1 GiB. It counts everything in the app's cache volume, and in each build +volume only the files modified after that volume was created, so a file the build produced is +never deleted and a page with no revalidate time keeps what it was prerendered into. Over the +limit, it deletes by last access until 80% of the limit remains, then any directory left +empty, and logs the count under `nextship-cache-guard`. Access times on ext4's default +`relatime` are updated at most daily, which is fine-grained enough for a cache. + +Verified on a stand-in whose Docker storage was a 12 GB ext4 disk, limit 1199 MiB, with the +streaming fixture deployed: the files the guard counted were exactly the optimized image and +the six files of the regenerated `/isr` page, none of the build's own. With 1.4 GB of filler +last read in 2020, a run removed the five oldest files, 500 MiB, down to 901 MiB, kept the +image and the page, and logged it. With the real entries made the oldest, a run removed +them with the filler; `/isr` and `/` answered 200, the image was encoded again and then +cached, `index.html` from the build was still there, and after a restart emptied Next.js's +memory `/isr` rendered anew and wrote its files back. + #### Day-two commands (Implemented) `env push` and `env rm` rewrite the env file from SSH stdin and start the live image again @@ -743,6 +765,22 @@ Server Actions key, and left the old server serving. - Only containers labelled `sh.nextship.managed=true` and `sh.nextship.app=` are ever stopped or removed. Other workloads and the global Docker daemon configuration are never modified. `destroy` never removes the server, Caddy, other apps or DNS. +- An app's container is hardened so that code an attacker runs through a flaw in the app + cannot stay. The root filesystem is read-only, so only the two volumes of + [Runtime writes](#runtime-writes-implemented) and a 64 MB `/tmp` are writable, and `/tmp` + is mounted `noexec`, emptied on restart. Every capability is dropped, `no-new-privileges` + stops a setuid binary from raising them, and a limit of 512 processes stops a fork bomb. + The trigger was the December 2025 break-ins through CVE-2025-55182 on self-hosted + Next.js, which edited `next.config.js` and lockfiles and ran miners dropped in `/tmp`. + Measured in a nextship image of the streaming fixture run with these flags and both + volumes: healthy in 4 s; `/stream` first byte 83 ms against a 2.04 s total; on-demand ISR + regenerated; the Edge route and the image optimizer answered; `docker diff` listed no + write outside the volumes; writing to the app directory failed with `Read-only file + system`; a binary copied to `/tmp` was refused with `Permission denied`; `CapEff` was + zero and `NoNewPrivs` 1; and the app ran 12 processes and threads under concurrent + streaming and image optimization. `conformance/vm/e2e.sh` now checks the same from inside + a deployed container, after it has regenerated a page and optimized an image, and passed + in full on a local Ubuntu 24.04 stand-in with every other check unchanged. #### What is verified, and what is not @@ -878,6 +916,7 @@ Not verified yet, and the live checklist in `docs/roadmap.md`: | **No CDN.** | Static assets and media are served by the container through Caddy | | **The owner owns the OS.** | `server add` enables security updates, but kernel reboots, disk and provider incidents are the owner's to watch | | **Rollback uses the current env file.** | Rolling back code does not roll back a variable changed since | +| **The app's files are read-only.** | An app that writes outside `.next`, such as uploads to a local folder, fails with `EROFS`, and `/tmp` holds 64 MB and cannot run a binary, which breaks packages that unpack an executable there, such as a headless Chromium. Keep uploads in object storage or a database | ## 10. Deploy lifecycle diff --git a/docs/roadmap.md b/docs/roadmap.md index bee1592..e05f808 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -267,6 +267,28 @@ Known from the Droplet runs, to fix or document here: `conformance/vm/e2e.sh` ne with no other app on it, and after adding swap `server add` prints `done swap already on`, the post-apply check's wording rather than what it did. +## The server target's next phase (Planned, not tied to a version) + +Chosen from what people self-hosting Next.js report as unsolved: open Next.js issues, +competitors' issue trackers and security advisories, and published break-ins, read in +September 2026. Built in this order, each shipped and verified on its own, because the ones +first are the ones a new user meets in their first ten minutes: + +| Order | Item | State and what decides it | +|---|---|---| +| 1 | **Hardened containers.** Read-only root filesystem, a `noexec` `/tmp`, no capabilities, `no-new-privileges`, a process limit | **Done.** See `design.md` §9.3, Security | +| 2 | **HTTPS on a bare IP.** A first deployment answers `https://` with a valid certificate, before any domain exists | Let's Encrypt made IP address certificates generally available on 2026-01-15, only as six day certificates. Caddy needs its `shortlived` profile for them, and Caddy issue #7399 reports IP issuance failing with it. Verify on a Droplet before building | +| 3 | **A cap on the cache volumes, and disk watched while apps run.** Least recently used eviction when an app's runtime writes pass their limit | **Done.** See `design.md` §9.3, Cache guard | +| 4 | **Skew protection and instant rollback.** The previous deployment's container keeps running for a grace period, Caddy sends each request to the build the page was loaded from, and a rollback within that period only reloads Caddy | Next.js issue #99165, open on 16.3.6, reports open tabs failing after a deployment even with `deploymentId` set, because a Server Action response does not carry the deployment id. A spike first establishes what each kind of request carries. Costs a second container's memory for the grace period | +| 5 | **The compatibility suite nightly against the newest canary, published on the site** | `conformance.yml` runs it weekly today. The cost is CI time, since it builds Next.js from source | +| 6 | **A preview deployment per pull request on the same server**, a subdomain per branch through Caddy, removed when the pull request closes | Needs a design first for keeping a preview away from production's env and database, wildcard DNS and its certificate, and the memory of a container per open pull request | +| 7 | **Deploy time measured stage by stage**, then shortened where nextship owns the time | A warm deployment to a server takes 16.5 s, and most of it is `next build`. No target time is promised until the stages are measured | +| 8 | **Build once, promote to another environment** with its own `NEXT_PUBLIC_*` values | A spike, not a commitment. The bundler folds a build-time value into the code around it, so a comparison against a placeholder cannot be undone by replacing the placeholder. Only plain string values could be replaced, and a promotion that finds a folded value has to refuse | +| 9 | **A migration plan from Vercel**: domains, cron jobs, routing rules and the names of env vars | Vercel's API never returns a sensitive env var's value, so the plan asks for those rather than importing blanks, for the reason `env pull` is not built | +| 10 | **Peak build memory in the build result**, and a warning near the limit | `server add` already adds swap below 4 GB, and remote builds are refused below 2 GB | + +Correctness across several servers stays under v2 below, with its trigger. + ## AWS: on demand, after the Lightsail streaming experiment Previously v1.1. Moved behind the VM target: an EC2 instance is a Linux server over SSH, which @@ -517,6 +539,7 @@ Recorded so the decision stays visible rather than looking like an oversight. | Lambda, or any serverless AWS compute | It would be the cheapest option by far, and that is not the deciding factor. Lambda cannot run `next start`, so the app has to be split into functions and the routing pipeline re-implemented: middleware matching, dynamic segments, the `rsc` and `_rsc` cache-key discipline, PPR resume, ISR through object storage and a queue, image optimization as its own function. That is the work `design.md` §2.1 exists to avoid, and the work that cost other projects years. It also cannot port: DigitalOcean has no Lambda, so a serverless-first design would make the driver interface a fiction | | `env pull` | App Platform never returns a stored secret, so a pull could only ever return `NEXT_PUBLIC_*` values and the names of the rest. `nextship env` already lists the names. A command that returns blanks for everything that matters is worse than no command | | Log forwarding to an external sink | Needs a destination and credentials that are the user's to choose. There is nothing to verify against, and shipping unverified infrastructure code is how the defects in this project's own history got made | +| Stripping `x-middleware-subrequest` at the proxy | The header drove CVE-2025-29927, which was fixed in Next.js 15.2.3. nextship refuses anything older than 16.2, so no version it deploys is affected, and a rule for a header no supported version honours would be decoration | | A dedicated health endpoint | Would mean replacing Next.js's `startServer` and owning keep-alive, upgrades and error handling on the most critical path in the system, to save one render every few seconds for apps that prerender nothing. The health path is chosen from the build's prerendered routes instead | --- diff --git a/docs/vm.md b/docs/vm.md index 712c3d8..3339b93 100644 --- a/docs/vm.md +++ b/docs/vm.md @@ -53,6 +53,7 @@ only what is not already done, which is why a second run changes nothing. | `updates` | Installs security updates automatically with unattended-upgrades, using the distribution's default security origins | `--no-auto-updates` | | `firewall` | If ufw is installed, allows the SSH port, 80 and 443, then enables it. SSH is allowed before anything is enabled | `--no-firewall` | | `watchdog` | A systemd timer that restarts a nextship container unhealthy for three checks in a row, logging why to the journal under `nextship-watchdog` | | +| `cache-guard` | A systemd timer that, every ten minutes, keeps what each app has written at runtime under 10% of the disk, and never under 1 GiB, by deleting what was used least recently. Logged to the journal under `nextship-cache-guard` | | | `ssh-hardening` | Turns off password and keyboard-interactive logins and allows root by key only, in `/etc/ssh/sshd_config.d/10-nextship.conf`. Runs last, only after the `nextship` login worked, and removes its file again if `sshd -t` rejects it | `--no-ssh-hardening` | Docker publishes Caddy's ports itself, which bypasses ufw for those two ports. That is @@ -75,6 +76,14 @@ does not protect any port you publish from another container yourself. privileged container, so anyone who can log in as `nextship` controls the server. Treat its keys as root's. It is a separate user so nextship's files and containers have one owner, not to limit what that owner can do. +- **An app cannot change its own files.** Its container's filesystem is read-only apart + from the two volumes Next.js writes to and a 64 MB `/tmp` that is emptied on restart and + cannot run a binary. It keeps no Linux capability, cannot gain privileges, and is limited + to 512 processes. Code an attacker runs through a flaw in your app can neither rewrite + the app nor leave a program behind, and a restart removes whatever it wrote to `/tmp`. + The cost: an app that writes files outside `.next`, such as uploads to a local folder, + fails with `EROFS`, and a package that unpacks an executable into `/tmp`, such as a + headless Chromium, cannot run it. - **What is never touched:** containers without nextship's labels, the Docker daemon's configuration, and DNS. @@ -108,7 +117,12 @@ again. **Recovering.** A container that exits is restarted by Docker. A container that stays up but stops answering its health check is restarted by the watchdog after three failed -minutes, and the journal says so under `nextship-watchdog`. A lock left by a deployment +minutes, and the journal says so under `nextship-watchdog`. What Next.js writes while an +app runs, optimized images and pages it regenerates or renders for a new dynamic segment, +is kept under 10% of the disk per app by the cache guard, which deletes the least recently +used first and logs each run that deleted anything under `nextship-cache-guard`. Only what +was written at runtime is deleted, never a file the build produced, and Next.js renders a +deleted page or encodes a deleted image again on its next request. A lock left by a deployment that was killed is reported with its owner after 30 minutes and never removed automatically: confirm nothing is running, then remove `/etc/nextship/apps//lock` as the `nextship` user. diff --git a/packages/cli/runtime/vm/cache-guard.sh b/packages/cli/runtime/vm/cache-guard.sh new file mode 100644 index 0000000..ece93c6 --- /dev/null +++ b/packages/cli/runtime/vm/cache-guard.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# @nextship/cli: cache guard +# +# Run every ten minutes by a systemd timer that `nextship server add` installs. +# +# Next.js writes to disk for as long as an app serves: optimized images and the +# fetch cache in `.next/cache`, and every page it regenerates or renders for a new +# dynamic segment beside the build's own files in `.next`. Nothing in Next.js bounds +# either, and a crawler walking an app with dynamic segments can write gigabytes a +# day until the disk is full and every app on the server stops. This keeps what each +# app has written at runtime under a limit by deleting what was used least recently. +# +# Only what Next.js wrote at runtime is ever deleted: everything in the app's cache +# volume, and the files in a build volume modified after that volume was created. +# What the build produced is never touched, so a page with no revalidate time keeps +# the file it was prerendered into. Deleting an entry is safe while the app runs: +# Next.js reads a missing file as a cache miss and renders the page or encodes the +# image again. Every eviction is logged to the journal under nextship-cache-guard. +# +# Author: Ragul D +# Design: ../../../../docs/design.md §9.3 + +set -euo pipefail + +# Evicting down to below the limit, rather than to it, means one eviction is followed +# by some minutes of room instead of another eviction on the next run. +TARGET_PERCENT=80 +APPS=/etc/nextship/apps + +# An app may keep 10% of the disk Docker stores volumes on, and never less than 1 GiB, +# which is room for thousands of pages and optimized images. +limit_kib() { + local disk + disk="$(df -Pk "$1" | awk 'NR == 2 { print $2 }')" + local share=$(( disk / 10 )) + [ "$share" -lt 1048576 ] && share=1048576 + echo "$share" +} + +# Prints " " for each file Next.js wrote at runtime under +# the given volume directories. A build volume is given as "@": only its +# files modified after that moment count. +runtime_files() { + local entry dir since + for entry in "$@"; do + dir="${entry%@*}" + [ -d "$dir" ] || continue + if [ "$entry" = "$dir" ]; then + find "$dir" -type f -printf '%A@ %k %p\n' + else + since="${entry##*@}" + find "$dir" -type f -newermt "@$since" -printf '%A@ %k %p\n' + fi + done +} + +# Deletes the least recently used runtime files of one app until what remains is at +# most TARGET_PERCENT of the limit. Prints the number of files and KiB it removed. +evict() { + local limit="$1" + shift + local listing total target removed=0 freed=0 atime kib path entry + listing="$(runtime_files "$@" | sort -n)" + total="$(awk '{ sum += $2 } END { print sum + 0 }' <<< "$listing")" + if [ "$total" -le "$limit" ]; then + echo "0 0" + return + fi + target=$(( limit * TARGET_PERCENT / 100 )) + while read -r atime kib path; do + [ -n "$path" ] || continue + [ "$total" -le "$target" ] && break + rm -f -- "$path" + total=$(( total - kib )) + removed=$(( removed + 1 )) + freed=$(( freed + kib )) + done <<< "$listing" + # An optimized image is a directory holding one file, so its directory goes too. + for entry in "$@"; do + [ -d "${entry%@*}" ] && find "${entry%@*}" -mindepth 1 -type d -empty -delete + done + echo "$removed $freed" +} + +main() { + [ -d "$APPS" ] || exit 0 + local app name volumes limit mount created result + for app in $(ls "$APPS"); do + volumes=() + mount="$(docker volume inspect -f '{{.Mountpoint}}' "nextship-$app-cache" 2> /dev/null || true)" + [ -n "$mount" ] && volumes+=("$mount") + for name in $(docker volume ls -q --filter "name=^nextship-$app-build-dpl-"); do + mount="$(docker volume inspect -f '{{.Mountpoint}}' "$name")" + created="$(date -d "$(docker volume inspect -f '{{.CreatedAt}}' "$name")" +%s)" + volumes+=("$mount@$created") + done + [ "${#volumes[@]}" -gt 0 ] || continue + limit="$(limit_kib "${volumes[0]%@*}")" + result="$(evict "$limit" "${volumes[@]}")" + if [ "${result%% *}" != 0 ]; then + logger -t nextship-cache-guard "$app: removed ${result%% *} least recently used cache files, $(( ${result##* } / 1024 )) MiB, to stay under its $(( limit / 1024 )) MiB limit" + fi + done +} + +# Sourced by its tests, which call evict on a directory; run by the timer otherwise. +if [ "${BASH_SOURCE[0]}" = "$0" ]; then main; fi diff --git a/packages/cli/runtime/vm/setup.sh b/packages/cli/runtime/vm/setup.sh index 55c80ec..647df88 100755 --- a/packages/cli/runtime/vm/setup.sh +++ b/packages/cli/runtime/vm/setup.sh @@ -26,6 +26,8 @@ # NEXTSHIP_SSH_PORT the port that connection used, which the firewall keeps open # NEXTSHIP_MIN_DOCKER the oldest Docker major nextship builds with (docker.ts) # NEXTSHIP_WATCHDOG_B64 the watchdog script, base64, installed by the watchdog step +# NEXTSHIP_CACHE_GUARD_B64 the cache guard script, base64, installed by the +# cache-guard step # NEXTSHIP_OS_RELEASE tests only: an os-release file to read instead of # /etc/os-release, so the OS check runs against fixtures # @@ -36,12 +38,12 @@ set -euo pipefail # Raised whenever a step changes, so `server status` can tell a server set up by an # older nextship to run `server add --yes` again. -SETUP_VERSION=2 +SETUP_VERSION=3 # Pinned to a minor so a server gets the same Caddy until nextship moves it on purpose. CADDY_IMAGE="caddy:2.10" -STEPS="os arch resources ports docker user dirs network caddy journald swap updates firewall watchdog ssh-hardening" +STEPS="os arch resources ports docker user dirs network caddy journald swap updates firewall watchdog cache-guard ssh-hardening" OS_RELEASE="${NEXTSHIP_OS_RELEASE:-/etc/os-release}" MIN_DOCKER="${NEXTSHIP_MIN_DOCKER:-23}" @@ -382,6 +384,53 @@ apply_watchdog() { systemctl restart nextship-watchdog.timer } +# ---------------------------------------------------------------- cache-guard + +CACHE_GUARD=/usr/local/lib/nextship/cache-guard.sh +CACHE_GUARD_SERVICE="[Unit] +Description=Keep what nextship apps write at runtime under their disk limit + +[Service] +Type=oneshot +ExecStart=$CACHE_GUARD +" +# Ten minutes because each run walks every file an app has cached, which is too much +# work for every minute, and a disk does not fill in ten minutes at the rates seen. +CACHE_GUARD_TIMER="[Unit] +Description=Run the nextship cache guard every ten minutes + +[Timer] +OnBootSec=5min +OnUnitActiveSec=10min +AccuracySec=1min + +[Install] +WantedBy=timers.target +" + +check_cache_guard() { + local expected="" + [ -n "${NEXTSHIP_CACHE_GUARD_B64:-}" ] && expected="$(printf '%s' "$NEXTSHIP_CACHE_GUARD_B64" | base64 -d)" + if [ ! -f "$CACHE_GUARD" ] || [ "$(cat "$CACHE_GUARD")" != "$expected" ] || + [ "$(cat /etc/systemd/system/nextship-cache-guard.timer 2> /dev/null)" != "$(printf '%s' "$CACHE_GUARD_TIMER")" ] || + [ "$(cat /etc/systemd/system/nextship-cache-guard.service 2> /dev/null)" != "$(printf '%s' "$CACHE_GUARD_SERVICE")" ]; then + result change "install a cache guard that keeps each app's runtime cache under 10% of the disk" + return + fi + if ! systemctl is-active --quiet nextship-cache-guard.timer; then result change "start the cache guard timer"; return; fi + result ok "cache guard runs every ten minutes" +} +apply_cache_guard() { + install -d -m 0755 /usr/local/lib/nextship + printf '%s' "$NEXTSHIP_CACHE_GUARD_B64" | base64 -d > "$CACHE_GUARD" + chmod 0755 "$CACHE_GUARD" + printf '%s' "$CACHE_GUARD_SERVICE" > /etc/systemd/system/nextship-cache-guard.service + printf '%s' "$CACHE_GUARD_TIMER" > /etc/systemd/system/nextship-cache-guard.timer + systemctl daemon-reload + systemctl enable nextship-cache-guard.timer > /dev/null 2>&1 + systemctl restart nextship-cache-guard.timer +} + # -------------------------------------------------------------- ssh-hardening # Named 10- because sshd keeps the first value it reads and reads drop-ins in diff --git a/packages/cli/scripts/verify-pack.mjs b/packages/cli/scripts/verify-pack.mjs index 20a949c..9ff6114 100644 --- a/packages/cli/scripts/verify-pack.mjs +++ b/packages/cli/scripts/verify-pack.mjs @@ -27,7 +27,7 @@ const manifest = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), * The adapter is copied into every build, and `server add` sends the two VM * scripts to the server; the rest are the README and what Apache-2.0 requires. */ -const REQUIRED = ['runtime/adapter.mjs', 'runtime/vm/setup.sh', 'runtime/vm/watchdog.sh', 'README.md', 'LICENSE', 'NOTICE'] +const REQUIRED = ['runtime/adapter.mjs', 'runtime/vm/setup.sh', 'runtime/vm/watchdog.sh', 'runtime/vm/cache-guard.sh', 'README.md', 'LICENSE', 'NOTICE'] const fail = (message) => { throw new Error(message) diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 0f50204..5d2744e 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -54,6 +54,7 @@ export const SETUP_STEPS = [ 'updates', 'firewall', 'watchdog', + 'cache-guard', 'ssh-hardening', ] as const @@ -198,15 +199,17 @@ function runtimeFile(name: string): string { ) } -/** The setup script with the watchdog it installs, ready to send on stdin. */ +/** The setup script with the watchdog and cache guard it installs, ready to send on stdin. */ async function setupInput(): Promise { - const [script, watchdog] = await Promise.all([ + const [script, watchdog, cacheGuard] = await Promise.all([ readFile(runtimeFile('setup.sh'), 'utf8'), readFile(runtimeFile('watchdog.sh'), 'utf8'), + readFile(runtimeFile('cache-guard.sh'), 'utf8'), ]) - // An assignment ahead of the script rather than an argument, so the command - // line stays short. Base64 has no characters a shell treats specially. - return `export NEXTSHIP_WATCHDOG_B64=${Buffer.from(watchdog).toString('base64')}\n${script}` + // Assignments ahead of the script rather than arguments, so the command line + // stays short. Base64 has no characters a shell treats specially. + const encode = (text: string) => Buffer.from(text).toString('base64') + return `export NEXTSHIP_WATCHDOG_B64=${encode(watchdog)}\nexport NEXTSHIP_CACHE_GUARD_B64=${encode(cacheGuard)}\n${script}` } /** The version setup.sh records on the server, read from the script this CLI ships. */ diff --git a/packages/cli/src/targets/vm/cache-guard.test.ts b/packages/cli/src/targets/vm/cache-guard.test.ts new file mode 100644 index 0000000..714229c --- /dev/null +++ b/packages/cli/src/targets/vm/cache-guard.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +// The guard runs on the server, which is Linux with GNU find; its `-printf` and +// `-newermt @` have no BSD equivalent, so these run where the server's tools are. +const linuxOnly = { skip: process.platform !== 'linux' } + +const GUARD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../runtime/vm/cache-guard.sh') + +/** A 64 KiB file whose last access is `accessed` seconds after the epoch and last change `modified`. */ +function entry(file: string, accessed: number, modified: number): void { + mkdirSync(path.dirname(file), { recursive: true }) + writeFileSync(file, Buffer.alloc(64 * 1024, 1)) + execFileSync('touch', ['-m', '-d', `@${modified}`, file]) + execFileSync('touch', ['-a', '-d', `@${accessed}`, file]) +} + +/** Runs `evict ` from the real script; returns " " it removed. */ +function evict(limitKib: number, ...volumes: string[]): string { + // The script is named by $1, not $0: sourced with $0 set to its own path, it would + // take itself to be run by the timer and start guarding this machine. + const script = 'guard="$1"; shift; source "$guard" && evict "$@"' + return execFileSync('bash', ['-c', script, 'cache-guard-test', GUARD, String(limitKib), ...volumes], { encoding: 'utf8' }).trim() +} + +function volume(): string { + return mkdtempSync(path.join(tmpdir(), 'nextship-cache-guard-')) +} + +test('nothing is removed while an app is under its limit', linuxOnly, () => { + const cache = volume() + entry(path.join(cache, 'images/a/1.webp'), 1000, 1000) + entry(path.join(cache, 'images/b/2.webp'), 2000, 2000) + assert.equal(evict(1024, cache), '0 0') + assert.ok(existsSync(path.join(cache, 'images/a/1.webp'))) + rmSync(cache, { recursive: true }) +}) + +test('over the limit, the least recently used go first, down to 80% of it, with their directories', linuxOnly, () => { + const cache = volume() + for (let i = 0; i < 10; i++) entry(path.join(cache, `images/${i}/img.webp`), 1000 + i, 1000) + // Ten 64 KiB entries against a 512 KiB limit: 640 KiB must come down to 409 KiB or + // less, which is four removed, and the four are the oldest by access. + const [files] = evict(512, cache).split(' ') + assert.equal(files, '4') + for (let i = 0; i < 4; i++) assert.ok(!existsSync(path.join(cache, `images/${i}`)), `entry ${i} should be evicted`) + for (let i = 4; i < 10; i++) assert.ok(existsSync(path.join(cache, `images/${i}/img.webp`)), `entry ${i} should be kept`) + rmSync(cache, { recursive: true }) +}) + +// The defect this prevents: a page with no revalidate time losing the file it was +// prerendered into, which Next.js would then have to render at runtime, where the +// data it was built from may not exist. +test('in a build volume, only files changed after it was created can go, however far over the limit', linuxOnly, () => { + const build = volume() + const created = 5000 + entry(path.join(build, 'server/app/about.html'), 100, created - 100) + entry(path.join(build, 'server/app/blog/one.html'), 200, created + 100) + entry(path.join(build, 'server/app/blog/two.html'), 300, created + 200) + const [files] = evict(64, `${build}@${created}`).split(' ') + assert.equal(files, '2') + assert.ok(existsSync(path.join(build, 'server/app/about.html'))) + assert.ok(!existsSync(path.join(build, 'server/app/blog'))) + rmSync(build, { recursive: true }) +}) + +test('an app\'s limit covers its cache and build volumes together', linuxOnly, () => { + const cache = volume() + const build = volume() + entry(path.join(cache, 'fetch-cache/old'), 1000, 6000) + entry(path.join(build, 'server/app/new.html'), 9000, 6000) + entry(path.join(build, 'server/app/newer.html'), 9500, 6000) + // 192 KiB against 128 must come down to 102 KiB or less: the two oldest by access + // go, one from each volume, and the newest stays. + assert.equal(evict(128, cache, `${build}@5000`).split(' ')[0], '2') + assert.ok(!existsSync(path.join(cache, 'fetch-cache/old'))) + assert.ok(existsSync(path.join(build, 'server/app/newer.html'))) + rmSync(cache, { recursive: true }) + rmSync(build, { recursive: true }) +}) diff --git a/packages/cli/src/targets/vm/vm-target.test.ts b/packages/cli/src/targets/vm/vm-target.test.ts index d3a6339..0a211c3 100644 --- a/packages/cli/src/targets/vm/vm-target.test.ts +++ b/packages/cli/src/targets/vm/vm-target.test.ts @@ -82,6 +82,22 @@ test('a deployment publishes no port and carries the labels removal is scoped by assert.equal(args.at(-1), 'shop:dpl-1a2b-3c4d') }) +// The defect this prevents: an attacker's code, run through a flaw in the app, +// rewriting the app's files or leaving a binary that survives a restart. +test('a deployment runs read-only with no capabilities, and only the volumes and a non-executable /tmp are writable', () => { + const args = run() + assert.ok(args.includes('--read-only')) + assert.deepEqual(value(args, '--tmpfs'), ['/tmp:rw,noexec,nosuid,nodev,size=64m']) + assert.deepEqual(value(args, '--cap-drop'), ['ALL']) + assert.deepEqual(value(args, '--security-opt'), ['no-new-privileges']) + assert.deepEqual(value(args, '--pids-limit'), ['512']) + assert.deepEqual(value(args, '--volume'), [ + 'nextship-shop-build-dpl-1a2b-3c4d:/src/.next', + 'nextship-shop-cache:/src/.next/cache', + ]) + assert.ok(!args.includes('--cap-add') && !args.includes('--privileged')) +}) + test('the health check matches the image, with a fast start interval only where Docker has one', () => { const args = run() assert.deepEqual(value(args, '--health-interval'), ['30s']) diff --git a/packages/cli/src/targets/vm/vm-target.ts b/packages/cli/src/targets/vm/vm-target.ts index 887dedd..7b2c2f3 100644 --- a/packages/cli/src/targets/vm/vm-target.ts +++ b/packages/cli/src/targets/vm/vm-target.ts @@ -177,6 +177,18 @@ export function healthCommand(healthPath: string | null): string { * HEALTHCHECK; the start interval makes Docker check every two seconds while the * container starts, where Docker supports it, so a healthy deployment switches in * seconds rather than after the first 30 second interval. + * + * The container is hardened so that code an attacker runs inside it cannot stay. + * The self-hosted break-ins through CVE-2025-55182 in December 2025 edited + * `next.config.js` and lockfiles and ran miners dropped in `/tmp`. Here the root + * filesystem is read-only, so the only writable places are the two volumes + * Next.js writes to and a `/tmp` that is emptied on restart and cannot hold an + * executable. No capability is kept, because the image runs as its `app` user on + * a port above 1024 and needs none, and nothing can gain privileges through a + * setuid binary. Measured in a nextship image: under concurrent streaming and + * image optimization the app ran 12 processes and threads, so the process limit + * stops a fork bomb without coming near a real app, and `docker diff` showed no + * write outside the volumes. */ export function runArguments(options: { name: string @@ -197,6 +209,11 @@ export function runArguments(options: { '--restart', 'unless-stopped', '--env-file', `${ETC}/apps/${name}/env`, '--memory', options.memory, + '--read-only', + '--tmpfs', '/tmp:rw,noexec,nosuid,nodev,size=64m', + '--cap-drop', 'ALL', + '--security-opt', 'no-new-privileges', + '--pids-limit', '512', '--log-driver', 'journald', '--log-opt', `tag=${name}`, '--volume', `${volumes.build}:${options.workdir}/.next`, diff --git a/site/content/docs/vm.mdx b/site/content/docs/vm.mdx index 5e194d8..a891319 100644 --- a/site/content/docs/vm.mdx +++ b/site/content/docs/vm.mdx @@ -33,8 +33,8 @@ The first command changes nothing. It shows the server's host key fingerprint, w compare with the one your provider shows, and every setup step with what it would change. The second installs Docker if needed, creates a `nextship` user and proves a login as it, runs Caddy on ports 80 and 443, limits the journal, enables security updates and ufw, -installs a watchdog for containers that stay unhealthy, and turns off SSH password logins -last. From then on the host key is pinned in `nextship.json` and a server presenting any +installs a watchdog for containers that stay unhealthy and a cache guard that keeps what each +app writes at runtime under a limit, and turns off SSH password logins last. From then on the host key is pinned in `nextship.json` and a server presenting any other key is refused. `--no-firewall`, `--no-auto-updates`, `--no-swap` and `--no-ssh-hardening` opt out of those @@ -92,5 +92,10 @@ Two things on the server exist nowhere else: `/etc/nextship/apps//env` and `/etc/nextship/apps//secrets`. Keep a copy of both. The `nextship` user is in the docker group, which is root-equivalent, so treat its keys as root's. +Each app's container is read-only apart from `.next` and a 64 MB `/tmp` that cannot run a +binary, with no Linux capabilities and a limit of 512 processes. Code an attacker runs through +a flaw in your app can neither rewrite it nor leave a program behind. An app that saves +uploads to a local folder fails with `EROFS`, so keep them in object storage or a database. + The full guide, including GitHub Actions and recovery, is [docs/vm.md](https://github.com/gowtham472/nextship/blob/main/docs/vm.md). From 91157cec6b722c21fbed2821a97b241041de9a02 Mon Sep 17 00:00:00 2001 From: ragul2105 Date: Mon, 28 Sep 2026 22:46:43 +0530 Subject: [PATCH 02/11] feat(conformance): run the suite nightly on canary and show it on the site The compatibility suite now runs every night against the newest Next.js canary instead of weekly. A scheduled run publishes its score to the evidence branch through conformance/publish-evidence.mjs: latest.json and ninety nights of history.json. A run that scored no suites or lacks its Next.js version is refused rather than published, and a run started by hand is never published. The evidence page reads latest.json in the browser. Checked in the browser pane in both states: with no file yet it says the result could not be read and links to the runs; with a result it shows the date, Next.js version and commit, suites and assertions passing, and the failing suites. The markdown served to agents gives the JSON's address. Not yet run on GitHub: the first scheduled run after this reaches main is what verifies the publish job. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/conformance.yml | 62 +++++++++++++++++- CHANGELOG.md | 5 ++ conformance/publish-evidence.mjs | 86 +++++++++++++++++++++++++ conformance/publish-evidence.test.mjs | 58 +++++++++++++++++ docs/roadmap.md | 2 +- site/components/docs/nightly-result.tsx | 85 ++++++++++++++++++++++++ site/components/mdx.tsx | 2 + site/content/docs/evidence.mdx | 8 ++- site/lib/evidence.ts | 24 +++++++ site/lib/markdown.ts | 6 +- 10 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 conformance/publish-evidence.mjs create mode 100644 conformance/publish-evidence.test.mjs create mode 100644 site/components/docs/nightly-result.tsx create mode 100644 site/lib/evidence.ts diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 0b7831e..caa63ac 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -11,6 +11,10 @@ # the suites it targets in minutes rather than an hour. The summary job scores # either. # +# The scheduled run is the whole suite against the newest canary, every night, and its +# score is published to the `evidence` branch, which the site's evidence page reads. A +# run started by hand is never published: it may be a subset, or an older ref. +# # Contract: nextjs.org/docs/app/api-reference/adapters/testing-adapters # Author: Gowtham name: conformance @@ -27,8 +31,9 @@ on: default: '' type: string schedule: - # Weekly, so a Next.js regression is found before a release rather than after. - - cron: '0 3 * * 1' + # Nightly, so a Next.js change that breaks self-hosting is found and shown the + # day it lands on canary, rather than when it reaches a release. + - cron: '0 3 * * *' jobs: build: @@ -37,6 +42,7 @@ jobs: timeout-minutes: 45 outputs: nextjs-sha: ${{ steps.ref.outputs.sha }} + nextjs-version: ${{ steps.ref.outputs.version }} groups: ${{ steps.groups.outputs.list }} steps: # The test matrix is computed here rather than from the input inside the @@ -63,7 +69,9 @@ jobs: fetch-depth: 25 - id: ref - run: echo "sha=$(git -C nextjs rev-parse HEAD)" >> "$GITHUB_OUTPUT" + run: | + echo "sha=$(git -C nextjs rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "version=$(node -p "require('./nextjs/packages/next/package.json').version")" >> "$GITHUB_OUTPUT" # Building Next.js from source is most of this job's time, and it depends on # nothing but the Next.js commit. Keyed on that commit, every run against the @@ -217,3 +225,51 @@ jobs: name: summary path: summary.json retention-days: 90 + + publish: + name: Publish the nightly result + needs: [build, summary] + if: ${{ github.event_name == 'schedule' && needs.summary.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + # The one job that writes to the repository, and only to the evidence branch. + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: conformance/publish-evidence.mjs + sparse-checkout-cone-mode: false + path: adapter + + - uses: actions/setup-node@v4 + with: { node-version: '22' } + + - uses: actions/download-artifact@v4 + with: { name: summary } + + # The branch holds nothing but the published files, so it is created empty the + # first time rather than branched from main. + - name: Check out the evidence branch + run: | + if git ls-remote --exit-code --heads "https://github.com/${GITHUB_REPOSITORY}.git" evidence > /dev/null; then + git clone --depth 1 --branch evidence "https://x-access-token:${{ github.token }}@github.com/${GITHUB_REPOSITORY}.git" evidence + else + git init -q -b evidence evidence + git -C evidence remote add origin "https://x-access-token:${{ github.token }}@github.com/${GITHUB_REPOSITORY}.git" + fi + + - name: Write the result + env: + NEXTJS_VERSION: ${{ needs.build.outputs.nextjs-version }} + NEXTJS_SHA: ${{ needs.build.outputs.nextjs-sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node adapter/conformance/publish-evidence.mjs summary.json evidence + + - name: Push it + working-directory: evidence + run: | + git add latest.json history.json + git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit -q -m "Nightly suite on Next.js ${{ needs.build.outputs.nextjs-version }}" + git push -q origin evidence diff --git a/CHANGELOG.md b/CHANGELOG.md index c9fea2e..1b85529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ All notable changes to this repository. Attribution rules: `AGENTS.md` §1.1. crawlers until the disk filled and the site went down; nothing in Next.js bounds it. Setup version 3, so `server status` asks servers set up earlier to run `server add` again. (Ragul D) +- **The compatibility suite runs every night against the newest Next.js canary, and the + evidence page shows the result.** The scheduled run publishes its score to the + `evidence` branch, and the page reads it when it opens, saying so plainly when there is + nothing to read rather than showing a number. A run started by hand is never + published. (Ragul D) ### Changed diff --git a/conformance/publish-evidence.mjs b/conformance/publish-evidence.mjs new file mode 100644 index 0000000..00e33d9 --- /dev/null +++ b/conformance/publish-evidence.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/** + * Turns a scored compatibility suite run into the files the site reads. + * + * The nightly run against the newest Next.js canary is only evidence if someone can + * see it without opening GitHub Actions, so its score is written to the `evidence` + * branch, which the site's evidence page reads in the browser. `latest.json` is the + * last run; `history.json` keeps the last ninety, so a regression shows as a drop on + * a date rather than as a number that quietly changed. A run that scored no suite at + * all is refused rather than published, because "0 of 0" would read as a result. + * + * Usage: node publish-evidence.mjs + * Reads NEXTJS_VERSION, NEXTJS_SHA and RUN_URL from the environment, and writes + * latest.json and history.json into the directory, keeping the history already there. + * + * Author: Ragul D + */ + +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export const HISTORY_LENGTH = 90 + +/** The published record of one run. Only the names of failing suites, so the file stays small. */ +export function nightlyRecord(summary, meta) { + if (!summary?.suites?.total) { + throw new Error('The run scored no suites, so there is nothing to publish. Check the test jobs of this run for why none reported.') + } + for (const [name, value] of Object.entries(meta)) { + if (!value) throw new Error(`${name} is not set. The workflow passes it from the build job; check that step's output.`) + } + return { + date: meta.date, + nextjs: { version: meta.version, sha: meta.sha }, + run: meta.runUrl, + suites: { passed: summary.suites.passed, total: summary.suites.total }, + assertions: { passed: summary.assertions.passed, total: summary.assertions.passed + summary.assertions.failed }, + failing: summary.failing.map((suite) => suite.name), + } +} + +/** The history with this run appended, one entry per date, the newest last, at most HISTORY_LENGTH long. */ +export function appendHistory(history, record) { + const entry = { + date: record.date, + version: record.nextjs.version, + suites: record.suites, + assertions: record.assertions, + } + // A run started again on the same day replaces that day's entry rather than + // counting twice. + const day = (date) => date.slice(0, 10) + return [...history.filter((previous) => day(previous.date) !== day(entry.date)), entry].slice(-HISTORY_LENGTH) +} + +async function main() { + const [summaryPath, directory] = process.argv.slice(2) + if (!summaryPath || !directory) { + console.error('usage: node publish-evidence.mjs ') + process.exit(2) + } + const summary = JSON.parse(await readFile(summaryPath, 'utf8')) + const record = nightlyRecord(summary, { + date: new Date().toISOString(), + version: process.env.NEXTJS_VERSION, + sha: process.env.NEXTJS_SHA, + runUrl: process.env.RUN_URL, + }) + + const historyPath = path.join(directory, 'history.json') + let history = [] + try { + history = JSON.parse(await readFile(historyPath, 'utf8')) + } catch (error) { + // Only a first publish has no history. A file that exists but cannot be read is + // stopped on, since writing over it would erase every earlier night. + if (error.code !== 'ENOENT') throw error + } + + await writeFile(path.join(directory, 'latest.json'), JSON.stringify(record, null, 2) + '\n') + await writeFile(historyPath, JSON.stringify(appendHistory(history, record), null, 2) + '\n') + console.log(`Published ${record.suites.passed} of ${record.suites.total} suites on Next.js ${record.nextjs.version}`) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) await main() diff --git a/conformance/publish-evidence.test.mjs b/conformance/publish-evidence.test.mjs new file mode 100644 index 0000000..49c95f0 --- /dev/null +++ b/conformance/publish-evidence.test.mjs @@ -0,0 +1,58 @@ +/** + * Tests for what the nightly run publishes to the site. + * + * Each case is a way the published evidence could say something the run did not + * show: a run that scored nothing reading as a result, a missing version, a rerun + * counted twice, or a history that grows without end. + * + * Run with: node --test conformance/*.test.mjs + * Author: Ragul D + */ + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { HISTORY_LENGTH, appendHistory, nightlyRecord } from './publish-evidence.mjs' + +const summary = { + suites: { total: 1123, passed: 1121, failed: 2 }, + assertions: { passed: 3590, failed: 9 }, + failing: [ + { name: 'test/e2e/a.test.ts', passed: 1, failed: 5, failures: ['x'] }, + { name: 'test/e2e/b.test.ts', passed: 0, failed: 4, failures: ['y'] }, + ], +} +const meta = { date: '2026-09-29T03:40:00.000Z', version: '16.5.0-canary.3', sha: 'abc123', runUrl: 'https://github.com/gowtham472/nextship/actions/runs/1' } + +test('a run is published with its totals, its Next.js version and the names of what failed', () => { + assert.deepEqual(nightlyRecord(summary, meta), { + date: meta.date, + nextjs: { version: '16.5.0-canary.3', sha: 'abc123' }, + run: meta.runUrl, + suites: { passed: 1121, total: 1123 }, + assertions: { passed: 3590, total: 3599 }, + failing: ['test/e2e/a.test.ts', 'test/e2e/b.test.ts'], + }) +}) + +test('a run that scored no suites is refused, so "0 of 0" is never shown as a result', () => { + assert.throws(() => nightlyRecord({ ...summary, suites: { total: 0, passed: 0, failed: 0 } }, meta), /scored no suites/) +}) + +test('a run without its Next.js version is refused rather than published unlabelled', () => { + assert.throws(() => nightlyRecord(summary, { ...meta, version: undefined }), /version is not set/) +}) + +test('a second run on the same day replaces that day, and history keeps only the newest', () => { + const record = nightlyRecord(summary, meta) + const rerun = { ...record, date: '2026-09-29T09:00:00.000Z', suites: { passed: 1123, total: 1123 } } + const history = appendHistory(appendHistory([], record), rerun) + assert.equal(history.length, 1) + assert.equal(history[0].suites.passed, 1123) + + const long = Array.from({ length: HISTORY_LENGTH }, (_, i) => ({ ...history[0], date: `2026-01-${String(i + 1).padStart(2, '0')}T03:00:00Z` })) + const next = appendHistory(long, record) + assert.equal(next.length, HISTORY_LENGTH) + assert.equal(next.at(-1).date, meta.date) + assert.equal(next[0].date, long[1].date) +}) diff --git a/docs/roadmap.md b/docs/roadmap.md index e05f808..2e9eef3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -280,7 +280,7 @@ first are the ones a new user meets in their first ten minutes: | 2 | **HTTPS on a bare IP.** A first deployment answers `https://` with a valid certificate, before any domain exists | Let's Encrypt made IP address certificates generally available on 2026-01-15, only as six day certificates. Caddy needs its `shortlived` profile for them, and Caddy issue #7399 reports IP issuance failing with it. Verify on a Droplet before building | | 3 | **A cap on the cache volumes, and disk watched while apps run.** Least recently used eviction when an app's runtime writes pass their limit | **Done.** See `design.md` §9.3, Cache guard | | 4 | **Skew protection and instant rollback.** The previous deployment's container keeps running for a grace period, Caddy sends each request to the build the page was loaded from, and a rollback within that period only reloads Caddy | Next.js issue #99165, open on 16.3.6, reports open tabs failing after a deployment even with `deploymentId` set, because a Server Action response does not carry the deployment id. A spike first establishes what each kind of request carries. Costs a second container's memory for the grace period | -| 5 | **The compatibility suite nightly against the newest canary, published on the site** | `conformance.yml` runs it weekly today. The cost is CI time, since it builds Next.js from source | +| 5 | **The compatibility suite nightly against the newest canary, published on the site** | **Built, not yet run.** `conformance.yml` runs nightly and publishes to the `evidence` branch, which the evidence page reads. Verified once the first scheduled run publishes, which needs this merged to `main` | | 6 | **A preview deployment per pull request on the same server**, a subdomain per branch through Caddy, removed when the pull request closes | Needs a design first for keeping a preview away from production's env and database, wildcard DNS and its certificate, and the memory of a container per open pull request | | 7 | **Deploy time measured stage by stage**, then shortened where nextship owns the time | A warm deployment to a server takes 16.5 s, and most of it is `next build`. No target time is promised until the stages are measured | | 8 | **Build once, promote to another environment** with its own `NEXT_PUBLIC_*` values | A spike, not a commitment. The bundler folds a build-time value into the code around it, so a comparison against a placeholder cannot be undone by replacing the placeholder. Only plain string values could be replaced, and a promotion that finds a folded value has to refuse | diff --git a/site/components/docs/nightly-result.tsx b/site/components/docs/nightly-result.tsx new file mode 100644 index 0000000..4af98d9 --- /dev/null +++ b/site/components/docs/nightly-result.tsx @@ -0,0 +1,85 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { NIGHTLY_RESULT_URL, NIGHTLY_RUNS_URL, type NightlyResult as Result } from '@/lib/evidence' + +/** + * Last night's compatibility suite result, read from the `evidence` branch when the + * page opens. + * + * When the file cannot be read, the page says so and links to the runs instead of + * showing a number: before the first published night there is no file, and a figure + * rendered from nothing would be exactly the fabricated claim the evidence page exists + * to avoid. + * + * Author: Ragul D + */ +export function NightlyResult() { + const [state, setState] = useState<{ result: Result } | 'loading' | 'unavailable'>('loading') + + useEffect(() => { + const controller = new AbortController() + const load = async () => { + try { + const response = await fetch(NIGHTLY_RESULT_URL, { signal: controller.signal, cache: 'no-store' }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + setState({ result: (await response.json()) as Result }) + } catch { + // Unmounting aborts the fetch; nothing is left to update then. + if (!controller.signal.aborted) setState('unavailable') + } + } + void load() + return () => controller.abort() + }, []) + + const link = 'font-medium text-accent-text underline decoration-accent-text/30 underline-offset-4 hover:decoration-accent-text' + + if (state === 'loading') { + return

Reading last night's result…

+ } + if (state === 'unavailable') { + return ( +

+ Last night's result could not be read here. Every run is listed on{' '} + + GitHub Actions + + . +

+ ) + } + + const { result } = state + const rows = [ + ['Run', new Date(result.date).toUTCString().replace(' GMT', ' UTC')], + ['Next.js', `${result.nextjs.version} (${result.nextjs.sha.slice(0, 9)})`], + ['Suites passing', `${result.suites.passed} of ${result.suites.total}`], + ['Assertions passing', `${result.assertions.passed} of ${result.assertions.total}`], + ['Failing suites', result.failing.length === 0 ? 'None' : result.failing.join(', ')], + ] + return ( +
+ + + {rows.map(([label, value]) => ( + + + + + ))} + + + + +
+ {label} + {value}
+ + The full run on GitHub Actions + +
+
+ ) +} diff --git a/site/components/mdx.tsx b/site/components/mdx.tsx index 9c1cb61..70e5db2 100644 --- a/site/components/mdx.tsx +++ b/site/components/mdx.tsx @@ -3,6 +3,7 @@ import type { MDXComponents } from 'mdx/types' import { Callout } from '@/components/docs/callout' import { CodeBlock } from '@/components/docs/code-block' +import { NightlyResult } from '@/components/docs/nightly-result' /** * How MDX elements render. @@ -94,4 +95,5 @@ export const mdxComponents: MDXComponents = { pre: (props) =>
,
 
   Callout,
+  NightlyResult,
 }
diff --git a/site/content/docs/evidence.mdx b/site/content/docs/evidence.mdx
index bf30df1..9cb01da 100644
--- a/site/content/docs/evidence.mdx
+++ b/site/content/docs/evidence.mdx
@@ -35,8 +35,12 @@ Vercel, are in the
 
 A full run now takes 319 runner-minutes against 788, in the same 32 groups.
 
-The suite runs from GitHub Actions in this repository, weekly and on demand, so a
-regression shows up without anyone remembering to look.
+The suite runs from GitHub Actions in this repository every night against the newest
+Next.js canary, and on demand, so a change in Next.js that breaks self-hosting shows up the
+day it lands rather than when it reaches a release. The table above is the result recorded
+for a release. The nightly one is below, read from the repository when you open this page:
+
+
 
 ## Streaming
 
diff --git a/site/lib/evidence.ts b/site/lib/evidence.ts
new file mode 100644
index 0000000..023944c
--- /dev/null
+++ b/site/lib/evidence.ts
@@ -0,0 +1,24 @@
+/**
+ * Where the nightly compatibility suite publishes its result.
+ *
+ * `conformance/publish-evidence.mjs`, run by the scheduled workflow, writes
+ * `latest.json` to the repository's `evidence` branch. The page reads it in the
+ * browser rather than at build time, so the evidence page shows last night's run
+ * without the site being rebuilt every night.
+ *
+ * Author: Ragul D
+ */
+export const NIGHTLY_RESULT_URL = 'https://raw.githubusercontent.com/gowtham472/nextship/evidence/latest.json'
+
+/** Where every run, published or not, can be read in full. */
+export const NIGHTLY_RUNS_URL = 'https://github.com/gowtham472/nextship/actions/workflows/conformance.yml'
+
+/** What `publish-evidence.mjs` writes, as far as the page reads it. */
+export interface NightlyResult {
+  date: string
+  nextjs: { version: string; sha: string }
+  run: string
+  suites: { passed: number; total: number }
+  assertions: { passed: number; total: number }
+  failing: string[]
+}
diff --git a/site/lib/markdown.ts b/site/lib/markdown.ts
index be6063e..861d637 100644
--- a/site/lib/markdown.ts
+++ b/site/lib/markdown.ts
@@ -1,4 +1,5 @@
 import type { Doc } from './docs'
+import { NIGHTLY_RESULT_URL } from './evidence'
 import { SITE_URL } from './site'
 
 /**
@@ -6,7 +7,8 @@ import { SITE_URL } from './site'
  * than a rendered page.
  *
  * The MDX is already markdown apart from one component, so conversion is small and
- * strict: a callout becomes a blockquote, and links become absolute, pointing a docs
+ * strict: a callout becomes a blockquote, the nightly result becomes the address of its
+ * file, and links become absolute, pointing a docs
  * link at that page's markdown so an agent can keep reading in the same format. Any
  * other component fails the build, because an agent handed raw JSX would read it as
  * content, and a page that quietly lost a component is worse than one that refuses to
@@ -29,6 +31,8 @@ export function toMarkdown(doc: Doc): string {
         .map((line) => (line ? `> ${line}` : '>'))
         .join('\n')
     })
+    // Read in the browser on the page; an agent is pointed at the same file.
+    .replace(//g, `Last night's result is published as JSON at ${NIGHTLY_RESULT_URL}.`)
     .replace(/\]\((\/[^)\s]*)\)/g, (_, target: string) => `](${absolute(target)})`)
 
   const leftover = /<[A-Z][A-Za-z]*/.exec(body.replace(/```[\s\S]*?```/g, ''))

From d1778e84038266030cf87673fce578a0343075d8 Mon Sep 17 00:00:00 2001
From: ragul2105 
Date: Mon, 28 Sep 2026 22:46:43 +0530
Subject: [PATCH 03/11] docs: record why one build cannot be promoted to
 another environment

A spike built a client component with placeholder NEXT_PUBLIC_ values on
Next.js 16.3.4 with Turbopack. A value used as a string or in a template
literal survived and could be replaced, but FLAG === 'on' ? a : b
compiled to the losing branch alone, and URL.length > 30 ? a : b
compiled to the branch the placeholder's own length chose while the
placeholder still appeared in the bundle. The second is undetectable, so
a replacement would look complete and ship an app that decided on the
placeholder. The roadmap moves it to Deliberately not built.

Co-Authored-By: Claude Opus 5.5 
---
 docs/roadmap.md | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/docs/roadmap.md b/docs/roadmap.md
index 2e9eef3..303116d 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -283,7 +283,7 @@ first are the ones a new user meets in their first ten minutes:
 | 5 | **The compatibility suite nightly against the newest canary, published on the site** | **Built, not yet run.** `conformance.yml` runs nightly and publishes to the `evidence` branch, which the evidence page reads. Verified once the first scheduled run publishes, which needs this merged to `main` |
 | 6 | **A preview deployment per pull request on the same server**, a subdomain per branch through Caddy, removed when the pull request closes | Needs a design first for keeping a preview away from production's env and database, wildcard DNS and its certificate, and the memory of a container per open pull request |
 | 7 | **Deploy time measured stage by stage**, then shortened where nextship owns the time | A warm deployment to a server takes 16.5 s, and most of it is `next build`. No target time is promised until the stages are measured |
-| 8 | **Build once, promote to another environment** with its own `NEXT_PUBLIC_*` values | A spike, not a commitment. The bundler folds a build-time value into the code around it, so a comparison against a placeholder cannot be undone by replacing the placeholder. Only plain string values could be replaced, and a promotion that finds a folded value has to refuse |
+| 8 | **Build once, promote to another environment** with its own `NEXT_PUBLIC_*` values | **Concluded no.** See "Deliberately not built" |
 | 9 | **A migration plan from Vercel**: domains, cron jobs, routing rules and the names of env vars | Vercel's API never returns a sensitive env var's value, so the plan asks for those rather than importing blanks, for the reason `env pull` is not built |
 | 10 | **Peak build memory in the build result**, and a warning near the limit | `server add` already adds swap below 4 GB, and remote builds are refused below 2 GB |
 
@@ -539,6 +539,7 @@ Recorded so the decision stays visible rather than looking like an oversight.
 | Lambda, or any serverless AWS compute | It would be the cheapest option by far, and that is not the deciding factor. Lambda cannot run `next start`, so the app has to be split into functions and the routing pipeline re-implemented: middleware matching, dynamic segments, the `rsc` and `_rsc` cache-key discipline, PPR resume, ISR through object storage and a queue, image optimization as its own function. That is the work `design.md` §2.1 exists to avoid, and the work that cost other projects years. It also cannot port: DigitalOcean has no Lambda, so a serverless-first design would make the driver interface a fiction |
 | `env pull` | App Platform never returns a stored secret, so a pull could only ever return `NEXT_PUBLIC_*` values and the names of the rest. `nextship env` already lists the names. A command that returns blanks for everything that matters is worse than no command |
 | Log forwarding to an external sink | Needs a destination and credentials that are the user's to choose. There is nothing to verify against, and shipping unverified infrastructure code is how the defects in this project's own history got made |
+| Promoting one build to another environment by replacing `NEXT_PUBLIC_*` placeholders | Measured on Next.js 16.3.4 with Turbopack, building a client component with placeholder values: a value used as a string or inside a template literal survived and could be replaced, but `FLAG === 'on' ? a : b` compiled to the losing branch alone, with no trace of the flag, and `URL.length > 30 ? a : b` compiled to the branch the placeholder's own length chose, while the placeholder itself still appeared elsewhere in the bundle. The second is undetectable: a replacement would look complete and ship an app that decided on the placeholder. The placeholders also land in the prerendered HTML and the server chunks. Promoting safely means building again with the target's values, which `deploy` already does |
 | Stripping `x-middleware-subrequest` at the proxy | The header drove CVE-2025-29927, which was fixed in Next.js 15.2.3. nextship refuses anything older than 16.2, so no version it deploys is affected, and a rule for a header no supported version honours would be decoration |
 | A dedicated health endpoint | Would mean replacing Next.js's `startServer` and owning keep-alive, upgrades and error handling on the most critical path in the system, to save one render every few seconds for apps that prerender nothing. The health path is chosen from the build's prerendered routes instead |
 

From bd7141a5b5c58f5dc5568ed81ea1ea632d0bdc18 Mon Sep 17 00:00:00 2001
From: ragul2105 
Date: Tue, 29 Sep 2026 12:36:36 +0530
Subject: [PATCH 04/11] feat(vm): keep open tabs working across a deployment,
 instant rollback, previews

A tab loaded before a deployment names its build on every request:
x-deployment-id on navigations and Server Actions, ?dpl= on scripts,
measured in a browser against Next.js 16.3.4. The deployment a new build
replaces now keeps running for an hour, and Caddy routes requests naming
it there, with the live container as the fallback once it stops. That
sidesteps Next.js issue #99165, stale tabs failing on Server Actions. The
same build started again by env push is never kept, since both would
answer to one deployment id. A new retire timer stops the kept container
when its hour is over, and images prune keeps its image.

Rollback to the kept deployment switches Caddy to its running container
instead of starting one, 208 ms in the end-to-end test.

--preview  scopes deploy, rollback, logs, env, domain, images and
destroy to a second app on the same server, -. Its server
record names the app it previews, so a CI run with only the committed
nextship.json can find it; nextship.json is never written for it, its
site never claims the server's address and sends X-Robots-Tag noindex.
Found in review: destroy would have cleared the main app's id from
nextship.json for a preview; it no longer touches it.

e2e.sh passes 25 of 25 on a local Ubuntu 24.04 stand-in, with new
checks for skew routing, instant rollback, retire and a preview.

Co-Authored-By: Claude Opus 5.5 
---
 CHANGELOG.md                                  |  16 +++
 README.md                                     |  39 +++++--
 conformance/vm/e2e.sh                         | 105 ++++++++++++++++--
 docs/design.md                                |  53 ++++++++-
 docs/roadmap.md                               |   4 +-
 docs/vm.md                                    |  67 +++++++++++
 packages/cli/runtime/vm/retire.sh             |  39 +++++++
 packages/cli/runtime/vm/setup.sh              |  48 +++++++-
 packages/cli/scripts/verify-pack.mjs          |   2 +-
 packages/cli/src/deploy.ts                    |  58 +++++++---
 packages/cli/src/destroy.ts                   |   9 +-
 packages/cli/src/images.test.ts               |  29 +++--
 packages/cli/src/images.ts                    |  45 +++++---
 packages/cli/src/index.ts                     |  25 +++--
 packages/cli/src/owned-app.ts                 |  38 ++++++-
 packages/cli/src/preview.test.ts              |  55 +++++++++
 packages/cli/src/preview.ts                   |  65 +++++++++++
 packages/cli/src/server.ts                    |   8 +-
 packages/cli/src/targets/target.ts            |   9 ++
 packages/cli/src/targets/vm/caddy.test.ts     |  40 ++++++-
 packages/cli/src/targets/vm/caddy.ts          |  48 +++++++-
 .../cli/src/targets/vm/deployments.test.ts    |  75 ++++++++++---
 packages/cli/src/targets/vm/deployments.ts    |  54 ++++++++-
 packages/cli/src/targets/vm/vm-target.ts      | 101 +++++++++++++++--
 site/content/docs/vm.mdx                      |  17 ++-
 25 files changed, 925 insertions(+), 124 deletions(-)
 create mode 100644 packages/cli/runtime/vm/retire.sh
 create mode 100644 packages/cli/src/preview.test.ts
 create mode 100644 packages/cli/src/preview.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b85529..6628cbc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,22 @@ All notable changes to this repository. Attribution rules: `AGENTS.md` §1.1.
   crawlers until the disk filled and the site went down; nothing in Next.js bounds it.
   Setup version 3, so `server status` asks servers set up earlier to run `server add`
   again. (Ragul D)
+- **A tab opened before a deployment keeps working after it, and a rollback within the
+  hour is instant.** On a server, the deployment a new build replaces keeps running for an
+  hour, and Caddy sends it every request that names its build, as Next.js does on each
+  client navigation, Server Action and script an open tab loads; a page load gets the new
+  build. Without it such a tab asks the new build for files and Server Actions it lacks,
+  the failure Next.js issue #99165 still reports. `rollback` to that deployment switches
+  Caddy back to its running container, 208 ms in the end-to-end test, keeping its own env,
+  and a timer stops the kept container when its hour is over. `env push` and domain changes
+  keep nothing extra. (Ragul D)
+- **Previews.** `nextship deploy --preview ` deploys the current source as a second
+  app on the same server, with its own env file and domains, never taking the server's
+  address and asking crawlers not to index it. The same flag scopes `rollback`, `logs`,
+  `env`, `domain`, `images` and `destroy`. A preview is found again from the app it
+  previews, recorded on the server, so a CI run holding only the committed `nextship.json`
+  can update or remove it; `docs/vm.md` §6 has a workflow for one per pull request, not yet
+  run in GitHub Actions. (Ragul D)
 - **The compatibility suite runs every night against the newest Next.js canary, and the
   evidence page shows the result.** The scheduled run publishes its score to the
   `evidence` branch, and the page reads it when it opens, saying so plainly when there is
diff --git a/README.md b/README.md
index 16e9a34..de6a396 100644
--- a/README.md
+++ b/README.md
@@ -371,7 +371,7 @@ deployed, and a page that did not compile failed once, with no retry.
 ```
 
 A deployment starts a new container with no published port, waits for Docker to report it
-healthy, then points Caddy at it and stops the previous one. Nothing a visitor sees
+healthy, then points Caddy at it. Nothing a visitor sees
 changes until the new container is healthy, so a deployment that fails leaves the previous
 one serving, and a deployment that succeeds drops no request. Its last log lines are
 printed when it fails. The first app deployed on a server answers `http://`;
@@ -382,8 +382,27 @@ so every machine that deploys, including CI, builds with the same key. A key alr
 your local file is copied to the server on the first deploy. If the server and your local
 file hold different keys, `deploy` refuses rather than choosing one.
 
-`rollback` on a server starts the earlier deployment's image again the same way. It uses
-the current env file, not the one that deployment first ran with, and the plan says so.
+**A tab opened before a deployment keeps working after it.** The deployment a new build
+replaces keeps running for an hour, and Caddy sends it every request that names its build,
+which Next.js does on each client navigation, Server Action and script an open tab loads.
+Without this, such a tab asks the new build for files and Server Actions it does not have.
+A page load names no build and gets the new one. After the hour, the old container stops
+and a tab still open from before loads the new build on its next navigation. The cost is a
+second container's memory for that hour. A change that starts the same build again, such
+as `env push`, keeps nothing extra.
+
+`rollback` on a server to the deployment replaced within the last hour is instant: that
+container is still running, so Caddy is pointed back at it and nothing is built or
+started, and it keeps the env it ran with. A rollback further back starts that
+deployment's image again with the current env file, and the plan says which is which.
+
+**Previews.** `nextship deploy --preview pr-42` deploys the current source as a second app on
+the same server, `-pr-42`, with its own env file and domains, and never writes
+`nextship.json`. The same flag scopes `rollback`, `logs`, `env`, `domain`, `images` and
+`destroy` to it, and the preview is found again from the app it previews, so a CI run with
+only the committed `nextship.json` can update or remove it. A preview answers only on the
+domains attached to it and asks crawlers not to index it. A workflow that deploys one per
+pull request is in [`docs/vm.md`](./docs/vm.md) §6.
 
 ### `nextship rollback`
 
@@ -698,8 +717,9 @@ server presenting any other key is refused.
 With `--yes` it installs Docker CE if the server has none, creates a `nextship` user and
 proves a login as that user from your machine, runs Caddy on ports 80 and 443, limits the
 journal, enables security updates and ufw, installs a watchdog for containers that stay
-unhealthy and a cache guard that keeps what each app writes at runtime under a limit, and
-turns off SSH password logins last. Every later command connects as
+unhealthy, a cache guard that keeps what each app writes at runtime under a limit and a
+timer that stops a replaced deployment after its hour, and turns off SSH password logins
+last. Every later command connects as
 `nextship`. Running it again against a server that is set up changes nothing; running it
 from a second project records the same server for that project. Each step is described in
 [`docs/vm.md`](./docs/vm.md).
@@ -911,6 +931,8 @@ On a server, `server add` creates:
 /etc/systemd/system/nextship-watchdog.* the timer that runs it every minute
 /usr/local/lib/nextship/cache-guard.sh  keeps each app's runtime cache under 10% of the disk
 /etc/systemd/system/nextship-cache-guard.* the timer that runs it every ten minutes
+/usr/local/lib/nextship/retire.sh       stops a replaced deployment once its hour for open tabs is over
+/etc/systemd/system/nextship-retire.*   the timer that runs it every minute
 /etc/systemd/journald.conf.d/nextship.conf   SystemMaxUse=500M, only if no limit was set
 /etc/ssh/sshd_config.d/10-nextship.conf      unless --no-ssh-hardening
 /etc/apt/apt.conf.d/20auto-upgrades          unless --no-auto-updates
@@ -1000,8 +1022,9 @@ deploy:
   is no CDN in front unless you add one, the operating system is yours to keep patched
   and rebooted, and backups are yours. The `nextship` user is root-equivalent. See
   [`docs/vm.md`](./docs/vm.md).
-- **On a server, rollback uses the current env file.** Rolling back code does not roll
-  back a variable changed since, which App Platform does.
+- **On a server, a rollback further back than the last hour uses the current env file.**
+  Rolling back code does not roll back a variable changed since, which App Platform does.
+  A rollback to the deployment replaced within the hour keeps its own env.
 - **On a server, only the first app deployed answers `http://`.** Every other app
   answers only on the domains attached to it.
 - **On a server, your app cannot write its own files.** The container is read-only apart
@@ -1091,7 +1114,7 @@ conformance/           scripts for the official Next.js adapter compatibility su
 packages/
   adapter/             Next.js Adapter API implementation, injected via NEXT_ADAPTER_PATH
   cli/                 every command; runtime/ holds the files copied into a build,
-                       and runtime/vm/ the setup, watchdog and cache guard scripts sent to a server
+                       and runtime/vm/ the setup and timer scripts sent to a server
 site/                  the marketing site and documentation, exported as static files
 ```
 
diff --git a/conformance/vm/e2e.sh b/conformance/vm/e2e.sh
index 69a3ef9..49d4d02 100755
--- a/conformance/vm/e2e.sh
+++ b/conformance/vm/e2e.sh
@@ -6,7 +6,10 @@
 # previous one serving, rollback returns to the previous deployment, env push takes effect
 # without downtime, a replaced deployment's logs are still readable, pruning keeps the
 # live image, a regenerated ISR page and an optimized image survive a container restart,
-# the app's container is read-only and cannot run what it writes, a domain gets its own site, and destroying one app leaves another serving.
+# the app's container is read-only and cannot run what it writes, a tab from before a
+# deployment reaches its own build, a rollback within the hour is instant, a preview
+# serves beside the app and leaves it alone when destroyed, a domain gets its own site,
+# and destroying one app leaves another serving.
 #
 # It ends by destroying both apps it created, so a server it ran against is left with only
 # what `server add` set up.
@@ -106,6 +109,18 @@ check "idempotence" "a second server add changes nothing"
 
 # ---------------------------------------------------------------------- deploy
 
+# A route that answers with a stamp written into its source, so a response says
+# which build served it. Each build below rewrites the stamp.
+stamp() {
+  mkdir -p app/api/build
+  printf 'export const dynamic = "force-dynamic"\nexport function GET() { return Response.json({ build: "%s" }) }\n' "$1" > app/api/build/route.js
+}
+# The deployment id a build names on every request an open tab makes, read from
+# the ?dpl= Next.js puts on the page's scripts.
+page_dpl() { curl -s "${URL}/" | grep -o 'dpl=dpl-[A-Za-z0-9_.-]*' | head -1 | cut -d= -f2; }
+build_of() { curl -s "$@" | sed -n 's/.*"build":"\([a-z]*\)".*/\1/p'; }
+
+stamp first
 commit "record the server"
 nextship deploy --yes
 curl -sf -o /dev/null "${URL}/" || fail "deploy" "${URL}/ does not answer after deploying"
@@ -118,7 +133,11 @@ check "edge" "/edge ran in the Edge runtime, through Caddy"
 
 # ------------------------------------------------------------- zero downtime
 
+FIRST_DPL="$(page_dpl)"
+FIRST_SCRIPT="$(curl -s "${URL}/" | grep -o '/_next/static/[^"]*?dpl=dpl-[A-Za-z0-9_.-]*' | head -1)"
+[ -n "${FIRST_DPL}" ] && [ -n "${FIRST_SCRIPT}" ] || fail "skew" "the first build's page named no deployment id on its scripts"
 echo "// second build $(date +%s)" >> app/layout.jsx
+stamp second
 commit "second build"
 poll / "${WORK}/codes"   & LOOP_PIDS+=($!)
 poll /stream "${WORK}/streams" & LOOP_PIDS+=($!)
@@ -130,6 +149,20 @@ wait "${LOOP_PIDS[@]}"
 LOOP_PIDS=()
 all_ok "${WORK}/codes" "zero downtime"
 all_ok "${WORK}/streams" "streams across the switch"
+
+# ------------------------------------------------------------ skew protection
+
+# A tab that loaded the first build keeps naming it: x-deployment-id on navigations
+# and Server Actions, ?dpl= on scripts. Those requests must reach the first build's
+# container, kept running, while a request naming nothing gets the new build.
+SECOND_DPL="$(page_dpl)"
+[ -n "${SECOND_DPL}" ] && [ "${SECOND_DPL}" != "${FIRST_DPL}" ] || fail "skew" "the second build names the same deployment id as the first"
+[ "$(build_of "${URL}/api/build")" = second ] || fail "skew" "a request naming no build did not get the new one"
+[ "$(build_of -H "X-Deployment-Id: ${FIRST_DPL}" "${URL}/api/build")" = first ] ||
+  fail "skew" "a request with the first build's x-deployment-id did not reach it"
+[ "$(build_of "${URL}/api/build?dpl=${FIRST_DPL}")" = first ] || fail "skew" "a request with ?dpl= for the first build did not reach it"
+[ "$(curl -s -o /dev/null -w '%{http_code}' "${URL}${FIRST_SCRIPT}")" = 200 ] || fail "skew" "the first build's script ${FIRST_SCRIPT} is gone"
+check "skew" "requests naming the first build reach it after the second went live, and the rest get the second"
 SECOND="$(nextship rollback | sed -n 's/^  current *\([^ ]*\).*/\1/p')"
 
 # ------------------------------------------------------------ failed startup
@@ -155,10 +188,34 @@ commit "remove the failing startup"
 
 TARGET="$(nextship rollback | sed -n 's/^  roll back *\([^ ]*\).*/\1/p')"
 [ -n "${TARGET}" ] || fail "rollback" "the plan named nothing to roll back to"
-nextship rollback --yes
+# The first build was kept running when the second replaced it, so going back to it
+# is a switch of the proxy, not a new container.
+ROLLED="$(nextship rollback --yes)"
+echo "${ROLLED}"
 [ "$(nextship rollback | sed -n 's/^  current *\([^ ]*\).*/\1/p')" = "${TARGET}" ] || fail "rollback" "the live deployment is not ${TARGET}"
 curl -sf -o /dev/null "${URL}/" || fail "rollback" "${URL}/ does not answer after rolling back"
 check "rollback" "${TARGET} serves again"
+SWITCH_MS="$(sed -n 's/.*which was still running, in \([0-9]*\) ms.*/\1/p' <<< "${ROLLED}")"
+[ -n "${SWITCH_MS}" ] || fail "instant rollback" "the rollback started a new container instead of switching to the one still running"
+[ "$(build_of "${URL}/api/build")" = first ] || fail "instant rollback" "the first build does not serve after rolling back to it"
+[ "$(build_of -H "X-Deployment-Id: ${SECOND_DPL}" "${URL}/api/build")" = second ] ||
+  fail "instant rollback" "the build rolled back from was not kept for the tabs that loaded it"
+check "instant rollback" "switched back to the first build's running container in ${SWITCH_MS} ms, keeping the second for its tabs"
+
+# ------------------------------------------------------------------- retire
+
+# Not waited out: the kept container's stop time is moved to the past and the timer's
+# service run, as the timer would an hour after the rollback.
+SUDO=""
+on_server "sudo -n true" 2> /dev/null && SUDO="sudo -n"
+KEPT="$(on_server "${SUDO} cut -d' ' -f1 /etc/nextship/apps/nextship-streaming-fixture/previous")"
+[ -n "${KEPT}" ] || fail "retire" "no kept container is recorded"
+on_server "printf '%s 1\n' ${KEPT} | ${SUDO} tee /etc/nextship/apps/nextship-streaming-fixture/previous > /dev/null && ${SUDO} systemctl start nextship-retire.service"
+[ "$(on_server "${SUDO} docker inspect -f '{{.State.Running}}' ${KEPT}")" = false ] || fail "retire" "${KEPT} still runs after its time ran out"
+[ "$(curl -s -o /dev/null -w '%{http_code}' -H "X-Deployment-Id: ${SECOND_DPL}" "${URL}/api/build")" = 200 ] ||
+  fail "retire" "a request naming the stopped build was not served by the live one"
+[ "$(build_of -H "X-Deployment-Id: ${SECOND_DPL}" "${URL}/api/build")" = first ] || fail "retire" "a request naming the stopped build got something other than the live build"
+check "retire" "the kept container stopped when its time ran out, and requests naming it fell back to the live build"
 
 # ---------------------------------------------------------------------- env
 
@@ -196,11 +253,14 @@ nextship images prune --keep 1 --yes
 # Output is captured before it is searched: grep -q exits at its first match, and the
 # closed pipe would fail the command under pipefail.
 IMAGES="$(nextship images)"
-KEPT="$(grep -c '^  dpl-' <<< "${IMAGES}" || true)"
-[ "${KEPT}" -eq 1 ] || fail "images prune" "${KEPT} images remain after keeping 1"
+REMAINING="$(grep -c '^  dpl-' <<< "${IMAGES}" || true)"
+# The build the env route deployment replaced is still kept running for its tabs,
+# and Docker cannot remove an image a running container uses, so it stays too.
+[ "${REMAINING}" -eq 2 ] || fail "images prune" "${REMAINING} images remain after keeping 1, where the live and the kept one were expected"
 grep -q 'deployed now' <<< "${IMAGES}" || fail "images prune" "the live image was removed"
+grep -q 'kept running for tabs' <<< "${IMAGES}" || fail "images prune" "the kept deployment's image was removed"
 curl -sf -o /dev/null "${URL}/" || fail "images prune" "the app stopped serving"
-check "images prune" "one image kept, the live one, and the app still serves"
+check "images prune" "the live image and the one still running for older tabs kept, nothing else, and the app still serves"
 
 # --------------------------------------------------------------- durability
 
@@ -257,8 +317,12 @@ check "durability" "an ISR page was regenerated and an image was optimized and c
 
 # A restart, not a redeploy: a redeploy builds a new image and so a new build
 # volume, which would prove nothing about what is kept.
-CONTAINER="$(on_server "${DOCKER} ps --filter label=sh.nextship.app=nextship-streaming-fixture --filter status=running --format '{{.Names}}'" | head -1)"
-[ -n "${CONTAINER}" ] || fail "durability" "no running container found for the app"
+# The live one by its build: the deployment it replaced, a different build, is kept
+# running beside it, and an older container of the live build is stopped.
+LIVE_TAG="$(nextship rollback | sed -n 's/^  current *\([^ ]*\).*/\1/p')"
+CONTAINER="$(on_server "${DOCKER} ps --filter label=sh.nextship.app=nextship-streaming-fixture --filter label=sh.nextship.image=${LIVE_TAG} --filter status=running --format '{{.Names}}'")"
+[ "$(wc -l <<< "${CONTAINER}" | tr -d ' ')" = 1 ] && [ -n "${CONTAINER}" ] ||
+  fail "durability" "expected one running container of the live build ${LIVE_TAG}, found: ${CONTAINER:-none}"
 
 # The restart and the wait are one connection: sixty round trips to poll a
 # health status is slower than the restart itself.
@@ -311,6 +375,33 @@ nextship domain add e2e.nextship.test --yes
   fail "domain add" "the domain did not get its own HTTPS site"
 check "domain add" "the domain redirects to HTTPS through its own site"
 
+# ------------------------------------------------------------------ preview
+
+# A preview of this app on the same server, from a build the app never ran. It must
+# serve that build, leave the app's own address and build alone, keep out of
+# nextship.json, ask crawlers not to index it, and go away without touching the app.
+MAIN_BUILD="$(build_of "${URL}/api/build")"
+RECORDED="$(cat nextship.json)"
+stamp preview
+commit "a preview build"
+nextship deploy --preview e2e --yes
+[ "$(cat nextship.json)" = "${RECORDED}" ] || fail "preview" "deploying a preview changed nextship.json"
+[ "$(build_of "${URL}/api/build")" = "${MAIN_BUILD}" ] || fail "preview" "the app's own address stopped serving its build"
+PREVIEW_CONTAINER="$(on_server "${DOCKER} ps --filter label=sh.nextship.app=nextship-streaming-fixture-e2e --filter status=running --format '{{.Names}}'")"
+[ -n "${PREVIEW_CONTAINER}" ] || fail "preview" "no running container for the preview"
+PREVIEW_BUILD="$(on_server "${DOCKER} exec ${PREVIEW_CONTAINER} node -e \"fetch('http://127.0.0.1:3000/api/build').then(r => r.json()).then(b => console.log(b.build))\"")"
+[ "${PREVIEW_BUILD}" = preview ] || fail "preview" "the preview serves ${PREVIEW_BUILD:-nothing}, not its own build"
+nextship domain add e2e-preview.nextship.test --preview e2e --yes
+[ "$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: e2e-preview.nextship.test' "${URL}/")" = 308 ] ||
+  fail "preview" "the preview's domain did not get its own HTTPS site"
+on_server "${SUDO} grep -q 'X-Robots-Tag \"noindex, nofollow\"' /etc/nextship/caddy/sites/nextship-streaming-fixture-e2e.caddy" ||
+  fail "preview" "the preview's site does not ask crawlers not to index it"
+if nextship logs --preview not-a-preview > /dev/null 2>&1; then fail "preview" "a preview that does not exist was not refused"; fi
+nextship destroy nextship-streaming-fixture-e2e --preview e2e --images --yes
+[ "$(cat nextship.json)" = "${RECORDED}" ] || fail "preview" "destroying the preview changed nextship.json"
+[ "$(build_of "${URL}/api/build")" = "${MAIN_BUILD}" ] || fail "preview" "the app stopped serving when its preview was destroyed"
+check "preview" "served its own build beside the app, kept out of nextship.json, asked crawlers not to index it, and was destroyed leaving the app serving"
+
 # -------------------------------------------------- a second app, then destroy
 
 SECOND_APP="${WORK}/second"
diff --git a/docs/design.md b/docs/design.md
index 358fed3..a24eced 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -640,11 +640,51 @@ both sides are refused; with none anywhere, one is generated and stored only on
    record the deployment as not served, and leave the previous container serving.
 5. Render the Caddy site pointing at the new container, validate it, move it into place
    and reload Caddy. A failed validation or reload restores the previous file.
-6. Stop the previous container (kept, not removed, until images are pruned) and update
-   `deployments.json`.
-
-Rollback runs steps 3 to 6 with an earlier deployment's image. **It uses the current env
-file**, which differs from App Platform, where a rollback restores the old spec. `env push`,
+6. Update `deployments.json`, and stop the previous containers (stopped, not removed, until
+   images are pruned), except the one this deployment replaced when it is a different
+   build: that keeps running for an hour, and `retire.sh` stops it then.
+
+**Skew protection.** A tab loaded before a deployment keeps naming its build on every
+request: `x-deployment-id` on client navigations and Server Actions and `?dpl=` on scripts
+and styles, measured in a browser against Next.js 16.3.4 with `deploymentId` set. A page
+load names none. So the Caddy site routes a request naming the replaced build to its
+container, with the live container second under `lb_policy first` and `fail_duration 30s`,
+and everything else to the live one. Once the replaced container stops, a request naming it
+fails over to the live one, which is what every request got before. Next.js issue #99165
+reports stale tabs failing even with `deploymentId` set because a Server Action response
+does not carry the deployment id; routing the request to the build that owns the action
+means the response is that build's own. The replaced build is kept only when it is a
+different image: `env push` and a domain change start the same image, and routing by a
+deployment id both containers share would send every tab, new ones included, to the one
+being replaced. `deployments.json` records the kept one with `keptUntil`, and beside it
+`previous` holds ` ` for `retire.sh`, run every minute by a timer
+`server add` installs, since the server has no JSON parser. `images prune` and the prune
+after a deployment keep the kept deployment's image, which Docker would refuse to remove.
+
+**Previews.** `--preview ` (`preview.ts`) scopes a command to the app
+`-` on the project's server. Its `app.json` records `previewOf`, the id of the
+app it previews, and `ownedApp` accepts a preview only when that id is the one in
+`nextship.json`, so a preview is found again by a CI run holding only the committed file and
+is never adopted by name alone. `deploy --preview` creates the record and never writes
+`nextship.json`; `destroy --preview` leaves it alone. A preview's Caddy site never claims the
+server's address and sends `X-Robots-Tag: noindex, nofollow` on every name. Its env file
+starts empty.
+
+Verified by `conformance/vm/e2e.sh` on a local Ubuntu 24.04 stand-in, 25 checks of 25:
+after a second build went live, a request naming the first build by `x-deployment-id` or by
+`?dpl=` was served by it, and the first build's script still loaded, while a request naming
+nothing got the second; rolling back to the first switched to its running container in 208 ms
+and kept the second for its tabs; with the kept container's stop time moved to the past, the
+retire service stopped it and a request naming it was served by the live build; a preview
+deployed from a build the app never ran served that build beside the app, left `nextship.json`
+and the app's address unchanged, sent `X-Robots-Tag` on its domain's site, and was destroyed
+leaving the app serving. Every earlier check passed unchanged beside them.
+
+Rollback to the kept deployment is a Caddy switch to its container, which is still running,
+and the deployment being left is kept in its place. The container keeps the env it started
+with. A rollback to anything older runs steps 3 to 6 with that deployment's image, and **it
+uses the current env file**, which differs from App Platform, where a rollback restores the
+old spec. `env push`,
 `env rm` and a domain change run the same sequence with the live image. After a deployment
 goes live, images beyond the newest five served deployments are removed with their
 stopped containers, which is safe on a server where removing an image frees its space at
@@ -915,7 +955,8 @@ Not verified yet, and the live checklist in `docs/roadmap.md`:
 | **One server.** | No failover: if the server is down, the app is down. Backups and recovery are the owner's |
 | **No CDN.** | Static assets and media are served by the container through Caddy |
 | **The owner owns the OS.** | `server add` enables security updates, but kernel reboots, disk and provider incidents are the owner's to watch |
-| **Rollback uses the current env file.** | Rolling back code does not roll back a variable changed since |
+| **A rollback older than an hour uses the current env file.** | Rolling back code does not roll back a variable changed since. A rollback to the deployment replaced within the hour keeps its own |
+| **The replaced deployment runs for an hour.** | Its container holds memory beside the live one for that hour, within the same memory limit per container. A tab open longer than that loads the new build on its next navigation |
 | **The app's files are read-only.** | An app that writes outside `.next`, such as uploads to a local folder, fails with `EROFS`, and `/tmp` holds 64 MB and cannot run a binary, which breaks packages that unpack an executable there, such as a headless Chromium. Keep uploads in object storage or a database |
 
 ## 10. Deploy lifecycle
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 303116d..ce4546f 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -279,9 +279,9 @@ first are the ones a new user meets in their first ten minutes:
 | 1 | **Hardened containers.** Read-only root filesystem, a `noexec` `/tmp`, no capabilities, `no-new-privileges`, a process limit | **Done.** See `design.md` §9.3, Security |
 | 2 | **HTTPS on a bare IP.** A first deployment answers `https://` with a valid certificate, before any domain exists | Let's Encrypt made IP address certificates generally available on 2026-01-15, only as six day certificates. Caddy needs its `shortlived` profile for them, and Caddy issue #7399 reports IP issuance failing with it. Verify on a Droplet before building |
 | 3 | **A cap on the cache volumes, and disk watched while apps run.** Least recently used eviction when an app's runtime writes pass their limit | **Done.** See `design.md` §9.3, Cache guard |
-| 4 | **Skew protection and instant rollback.** The previous deployment's container keeps running for a grace period, Caddy sends each request to the build the page was loaded from, and a rollback within that period only reloads Caddy | Next.js issue #99165, open on 16.3.6, reports open tabs failing after a deployment even with `deploymentId` set, because a Server Action response does not carry the deployment id. A spike first establishes what each kind of request carries. Costs a second container's memory for the grace period |
+| 4 | **Skew protection and instant rollback.** The previous deployment's container keeps running for an hour, Caddy sends each request to the build the page was loaded from, and a rollback within that hour only reloads Caddy | **Done.** See `design.md` §9.3, Release sequence |
 | 5 | **The compatibility suite nightly against the newest canary, published on the site** | **Built, not yet run.** `conformance.yml` runs nightly and publishes to the `evidence` branch, which the evidence page reads. Verified once the first scheduled run publishes, which needs this merged to `main` |
-| 6 | **A preview deployment per pull request on the same server**, a subdomain per branch through Caddy, removed when the pull request closes | Needs a design first for keeping a preview away from production's env and database, wildcard DNS and its certificate, and the memory of a container per open pull request |
+| 6 | **A preview deployment per pull request on the same server**, removed when the pull request closes | **Done**, `--preview `, verified end to end. The pull request workflow in `vm.md` §6 has not run in GitHub Actions |
 | 7 | **Deploy time measured stage by stage**, then shortened where nextship owns the time | A warm deployment to a server takes 16.5 s, and most of it is `next build`. No target time is promised until the stages are measured |
 | 8 | **Build once, promote to another environment** with its own `NEXT_PUBLIC_*` values | **Concluded no.** See "Deliberately not built" |
 | 9 | **A migration plan from Vercel**: domains, cron jobs, routing rules and the names of env vars | Vercel's API never returns a sensitive env var's value, so the plan asks for those rather than importing blanks, for the reason `env pull` is not built |
diff --git a/docs/vm.md b/docs/vm.md
index 3339b93..c4ea9f6 100644
--- a/docs/vm.md
+++ b/docs/vm.md
@@ -54,6 +54,7 @@ only what is not already done, which is why a second run changes nothing.
 | `firewall` | If ufw is installed, allows the SSH port, 80 and 443, then enables it. SSH is allowed before anything is enabled | `--no-firewall` |
 | `watchdog` | A systemd timer that restarts a nextship container unhealthy for three checks in a row, logging why to the journal under `nextship-watchdog` | |
 | `cache-guard` | A systemd timer that, every ten minutes, keeps what each app has written at runtime under 10% of the disk, and never under 1 GiB, by deleting what was used least recently. Logged to the journal under `nextship-cache-guard` | |
+| `retire` | A systemd timer that, every minute, stops the deployment a newer one replaced once its hour for open tabs is over, logged under `nextship-retire` | |
 | `ssh-hardening` | Turns off password and keyboard-interactive logins and allows root by key only, in `/etc/ssh/sshd_config.d/10-nextship.conf`. Runs last, only after the `nextship` login worked, and removes its file again if `sshd -t` rejects it | `--no-ssh-hardening` |
 
 Docker publishes Caddy's ports itself, which bypasses ufw for those two ports. That is
@@ -100,6 +101,7 @@ keeps on the server:
 |---|---|
 | `/etc/nextship/apps//app.json` | The app's id and its domains. The id is what `nextship.json` records; a directory with another id is refused |
 | `/etc/nextship/apps//deployments.json` | The deployment history rollback chooses from |
+| `/etc/nextship/apps//previous` | The replaced deployment kept running for open tabs, and when `retire.sh` stops it |
 | `/etc/nextship/apps//env` | Runtime variables, mode 0600, in Docker's env file format |
 | `/etc/nextship/apps//secrets` | The Server Actions key, mode 0600 |
 | `/etc/nextship/caddy/sites/.caddy` | The app's Caddy site, regenerated on every change |
@@ -192,6 +194,71 @@ refuse the second anyway, but the refusal would fail that workflow run.
 Not yet run in GitHub Actions: this workflow is written from what nextship does locally,
 and running it is on the v1.1 checklist.
 
+### A preview per pull request
+
+`--preview ` scopes a command to a preview: a second app on the same server named
+`-`, with its own containers, env file and domains. `deploy --preview `
+creates it the first time and updates it after that. Its record on the server names the app
+it previews, which is how a later run with only the committed `nextship.json` finds it again,
+and `nextship.json` is never written for it. A preview never takes the server's own address,
+answers only on the domains attached to it, and every response asks crawlers not to index
+it. It starts with an empty env file: push values meant for a preview with
+`nextship env push --preview `, and never production's database.
+
+Point a wildcard record, `*.preview.example.com`, at the server once. Each preview's domain
+then gets its own certificate from Caddy when it is added. Then add a second workflow:
+
+```yaml
+name: preview
+on:
+  pull_request:
+    types: [opened, synchronize, reopened, closed]
+concurrency: preview-${{ github.event.number }}
+permissions:
+  contents: read
+  pull-requests: write
+jobs:
+  preview:
+    runs-on: ubuntu-latest
+    env:
+      PREVIEW: pr-${{ github.event.number }}
+      HOST: pr-${{ github.event.number }}.preview.example.com
+      GH_TOKEN: ${{ github.token }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version: '22'
+      - run: npm ci
+      - name: Load the deploy key
+        run: |
+          eval "$(ssh-agent -s)"
+          echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
+          echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
+          ssh-add - <<< "${{ secrets.NEXTSHIP_SSH_KEY }}"
+      - name: Deploy the preview
+        if: github.event.action != 'closed'
+        run: |
+          npx --yes nextship-cli deploy --preview "$PREVIEW" --yes --build local
+          npx --yes nextship-cli domain --preview "$PREVIEW" | grep -q "$HOST" ||
+            npx --yes nextship-cli domain add "$HOST" --preview "$PREVIEW" --yes
+          gh pr comment ${{ github.event.number }} --body "Preview: https://$HOST"
+      - name: Remove the preview
+        if: github.event.action == 'closed'
+        run: npx --yes nextship-cli destroy "$(node -p "require('./nextship.json').name")-$PREVIEW" --preview "$PREVIEW" --images --yes
+```
+
+Each preview is a whole container, with the same memory limit as any app on the server, so
+the server's memory decides how many pull requests can have one at once.
+
+**A preview runs the pull request's code with the deploy key.** GitHub gives no secrets to a
+pull request from a fork, so only branches in the repository get a preview, but anyone who
+can push a branch can run code on the runner that holds a key to the server. Use this where
+everyone with push access would be trusted with the server anyway.
+
+Not yet run in GitHub Actions. What it runs is verified: `conformance/vm/e2e.sh` deploys a
+preview, attaches a domain, and destroys it (`design.md` §9.3).
+
 ## 7. Backups and recovery
 
 A server is one machine. Know what is on it that exists nowhere else:
diff --git a/packages/cli/runtime/vm/retire.sh b/packages/cli/runtime/vm/retire.sh
new file mode 100644
index 0000000..dfad6b2
--- /dev/null
+++ b/packages/cli/runtime/vm/retire.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+#
+# @nextship/cli: retire the previous deployment
+#
+# Run every minute by a systemd timer that `nextship server add` installs.
+#
+# After a deployment the one it replaced keeps running for an hour, so a tab that
+# loaded it can still fetch its files and call its Server Actions (caddy.ts). Each
+# app's `previous` file, written by nextship with its deployment history, names that
+# container and when it stops, as ` `. This stops it once
+# that moment has passed and empties the file. Caddy's route to it then fails over
+# to the live container, which is what such a request got before it was kept.
+#
+# Only a container labelled as this app's nextship container is ever stopped, so a
+# damaged file cannot stop anything else. Stopped, not removed: its logs stay
+# readable and the next deployment's cleanup removes it like any other.
+#
+# Author: Ragul D
+# Design: ../../../../docs/design.md §9.3
+
+set -euo pipefail
+
+APPS=/etc/nextship/apps
+[ -d "$APPS" ] || exit 0
+now="$(date +%s)"
+
+for file in "$APPS"/*/previous; do
+  [ -s "$file" ] || continue
+  app="$(basename "$(dirname "$file")")"
+  read -r container until < "$file" || true
+  case "$until" in '' | *[!0-9]*) logger -t nextship-retire "$app: unreadable $file, left alone"; continue ;; esac
+  [ "$now" -ge "$until" ] || continue
+  label="$(docker inspect -f '{{index .Config.Labels "sh.nextship.app"}}' "$container" 2> /dev/null || true)"
+  if [ "$label" = "$app" ]; then
+    # 30 s, the time a deployment gives a replaced container to finish its requests.
+    docker stop -t 30 "$container" > /dev/null && logger -t nextship-retire "$app: stopped $container, kept for the tabs that loaded it until now"
+  fi
+  : > "$file"
+done
diff --git a/packages/cli/runtime/vm/setup.sh b/packages/cli/runtime/vm/setup.sh
index 647df88..865c32c 100755
--- a/packages/cli/runtime/vm/setup.sh
+++ b/packages/cli/runtime/vm/setup.sh
@@ -28,6 +28,7 @@
 #   NEXTSHIP_WATCHDOG_B64 the watchdog script, base64, installed by the watchdog step
 #   NEXTSHIP_CACHE_GUARD_B64 the cache guard script, base64, installed by the
 #                         cache-guard step
+#   NEXTSHIP_RETIRE_B64   the retire script, base64, installed by the retire step
 #   NEXTSHIP_OS_RELEASE   tests only: an os-release file to read instead of
 #                         /etc/os-release, so the OS check runs against fixtures
 #
@@ -43,7 +44,7 @@ SETUP_VERSION=3
 # Pinned to a minor so a server gets the same Caddy until nextship moves it on purpose.
 CADDY_IMAGE="caddy:2.10"
 
-STEPS="os arch resources ports docker user dirs network caddy journald swap updates firewall watchdog cache-guard ssh-hardening"
+STEPS="os arch resources ports docker user dirs network caddy journald swap updates firewall watchdog cache-guard retire ssh-hardening"
 
 OS_RELEASE="${NEXTSHIP_OS_RELEASE:-/etc/os-release}"
 MIN_DOCKER="${NEXTSHIP_MIN_DOCKER:-23}"
@@ -431,6 +432,51 @@ apply_cache_guard() {
   systemctl restart nextship-cache-guard.timer
 }
 
+# --------------------------------------------------------------------- retire
+
+RETIRE=/usr/local/lib/nextship/retire.sh
+RETIRE_SERVICE="[Unit]
+Description=Stop the previous nextship deployment once its tabs have had their hour
+
+[Service]
+Type=oneshot
+ExecStart=$RETIRE
+"
+RETIRE_TIMER="[Unit]
+Description=Run the nextship retire check every minute
+
+[Timer]
+OnBootSec=1min
+OnUnitActiveSec=1min
+AccuracySec=10s
+
+[Install]
+WantedBy=timers.target
+"
+
+check_retire() {
+  local expected=""
+  [ -n "${NEXTSHIP_RETIRE_B64:-}" ] && expected="$(printf '%s' "$NEXTSHIP_RETIRE_B64" | base64 -d)"
+  if [ ! -f "$RETIRE" ] || [ "$(cat "$RETIRE")" != "$expected" ] ||
+    [ "$(cat /etc/systemd/system/nextship-retire.timer 2> /dev/null)" != "$(printf '%s' "$RETIRE_TIMER")" ] ||
+    [ "$(cat /etc/systemd/system/nextship-retire.service 2> /dev/null)" != "$(printf '%s' "$RETIRE_SERVICE")" ]; then
+    result change "install a timer that stops the previous deployment an hour after it was replaced"
+    return
+  fi
+  if ! systemctl is-active --quiet nextship-retire.timer; then result change "start the retire timer"; return; fi
+  result ok "previous deployments stop an hour after they are replaced"
+}
+apply_retire() {
+  install -d -m 0755 /usr/local/lib/nextship
+  printf '%s' "$NEXTSHIP_RETIRE_B64" | base64 -d > "$RETIRE"
+  chmod 0755 "$RETIRE"
+  printf '%s' "$RETIRE_SERVICE" > /etc/systemd/system/nextship-retire.service
+  printf '%s' "$RETIRE_TIMER" > /etc/systemd/system/nextship-retire.timer
+  systemctl daemon-reload
+  systemctl enable nextship-retire.timer > /dev/null 2>&1
+  systemctl restart nextship-retire.timer
+}
+
 # -------------------------------------------------------------- ssh-hardening
 
 # Named 10- because sshd keeps the first value it reads and reads drop-ins in
diff --git a/packages/cli/scripts/verify-pack.mjs b/packages/cli/scripts/verify-pack.mjs
index 9ff6114..a9129a3 100644
--- a/packages/cli/scripts/verify-pack.mjs
+++ b/packages/cli/scripts/verify-pack.mjs
@@ -27,7 +27,7 @@ const manifest = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'),
  * The adapter is copied into every build, and `server add` sends the two VM
  * scripts to the server; the rest are the README and what Apache-2.0 requires.
  */
-const REQUIRED = ['runtime/adapter.mjs', 'runtime/vm/setup.sh', 'runtime/vm/watchdog.sh', 'runtime/vm/cache-guard.sh', 'README.md', 'LICENSE', 'NOTICE']
+const REQUIRED = ['runtime/adapter.mjs', 'runtime/vm/setup.sh', 'runtime/vm/watchdog.sh', 'runtime/vm/cache-guard.sh', 'runtime/vm/retire.sh', 'README.md', 'LICENSE', 'NOTICE']
 
 const fail = (message) => {
   throw new Error(message)
diff --git a/packages/cli/src/deploy.ts b/packages/cli/src/deploy.ts
index c0bb878..429d65b 100644
--- a/packages/cli/src/deploy.ts
+++ b/packages/cli/src/deploy.ts
@@ -20,7 +20,8 @@ import { buildProject, storedEncryptionKey } from './build.js'
 import { packageImage } from './packaging.js'
 import { readConfig, writeConfig, TARGET_IDS, type BuildMode, type ProjectConfig, type TargetId } from './config.js'
 import { CONTAINER_PORT } from './image/dockerfile.js'
-import { client as apiClient } from './owned-app.js'
+import { client as apiClient, previewConfig } from './owned-app.js'
+import { selectedPreview } from './preview.js'
 import { DEFAULT_REGION } from './targets/digitalocean-target.js'
 import type { EnvRecord, Target } from './targets/target.js'
 import { detail, ok, step, warn } from './util/log.js'
@@ -104,15 +105,28 @@ export function settingsFor(
 
 export async function deploy(project: ProjectInfo, options: DeployOptions): Promise {
   const existing = await readConfig(project.root)
-  const settings = settingsFor(project.name, existing, options)
+  const preview = selectedPreview()
+  // A preview is of the deployed app, and is authorised by that app's id.
+  const previewOf = preview === null ? null : existing?.appId
+  if (preview !== null && !previewOf) {
+    throw new NextshipError('A preview is a preview of the deployed app, and this project has none yet.', 'Run `nextship deploy` first.')
+  }
+  const recorded = settingsFor(project.name, existing, options)
+  const settings = preview === null ? recorded : previewConfig(recorded, preview)
   const client = apiClient(settings, existing ? options.target : undefined)
   const name = settings.name
 
   // Read-only reconnaissance first, so the plan describes reality.
   const delivery = await client.planDelivery(name)
   const apps = await client.listApps()
-  const owned = existing?.appId ? apps.find((app) => app.id === existing.appId) : undefined
-  const nameClash = apps.find((app) => app.name === name && app.id !== existing?.appId)
+  const owned = previewOf
+    ? apps.find((app) => app.name === name && app.previewOf === previewOf)
+    : existing?.appId
+      ? apps.find((app) => app.id === existing.appId)
+      : undefined
+  const nameClash = previewOf
+    ? apps.find((app) => app.name === name && app.previewOf !== previewOf)
+    : apps.find((app) => app.name === name && app.id !== existing?.appId)
 
   if (nameClash) {
     throw new NextshipError(
@@ -120,7 +134,7 @@ export async function deploy(project: ProjectInfo, options: DeployOptions): Prom
       'nextship will not modify an app it does not own. Rename this project, or set a different name in nextship.json.'
     )
   }
-  if (existing?.appId && !owned) {
+  if (!previewOf && existing?.appId && !owned) {
     throw new NextshipError(
       `nextship.json records app ${existing.appId}, which no longer exists ${client.appScope}.`,
       'Remove the appId from nextship.json to create a new app, after confirming the old one is really gone.'
@@ -143,11 +157,15 @@ export async function deploy(project: ProjectInfo, options: DeployOptions): Prom
     delivery,
   })
   for (const line of lines) detail(line)
+  if (preview !== null) {
+    detail(`preview       of "${recorded.name}", with its own env file and domains, and asks crawlers not to index it`)
+    if (!owned) detail(`              answers nothing until \`nextship domain add  --preview ${preview}\``)
+  }
   if (preserved.length > 0) detail(`preserved     ${preserved.join(', ')}, kept as they are`)
   detail(`untouched     ${apps.length} existing app(s) ${client.appScope}`)
   detail(client.deployRemoves ?? 'nothing is ever deleted by this command')
 
-  warnAboutMissingRuntimeEnv(project, existingEnv, owned !== undefined)
+  warnAboutMissingRuntimeEnv(project, existingEnv, owned !== undefined, preview)
 
   if (!options.confirmed) {
     ok('This was a plan only. Nothing was created or changed.')
@@ -205,6 +223,7 @@ export async function deploy(project: ProjectInfo, options: DeployOptions): Prom
     instanceSize: options.instanceSize ?? null,
     memory: options.memory ?? null,
     healthPath: build.manifest.healthPath,
+    ...(previewOf ? { previewOf } : {}),
   })
   const appId = released.appId
 
@@ -224,14 +243,19 @@ export async function deploy(project: ProjectInfo, options: DeployOptions): Prom
   let handedOff = false
   try {
     // Written before waiting, so a timeout still leaves the app recorded as ours
-    // rather than orphaned and unadoptable on the next run.
-    const config: ProjectConfig = {
-      ...settings,
-      ...(delivered.store ? { registry: delivered.store } : {}),
-      appId,
+    // rather than orphaned and unadoptable on the next run. A preview is recorded on
+    // the server instead, where the run that removes it will look.
+    if (previewOf) {
+      detail(`recorded on the server as a preview of "${recorded.name}"`)
+    } else {
+      const config: ProjectConfig = {
+        ...settings,
+        ...(delivered.store ? { registry: delivered.store } : {}),
+        appId,
+      }
+      await writeConfig(project.root, config)
+      detail('recorded in nextship.json')
     }
-    await writeConfig(project.root, config)
-    detail('recorded in nextship.json')
 
     if (!released.deploymentId) {
       warn(`${client.displayName} reported no deployment to follow. Check its control panel.`)
@@ -262,14 +286,16 @@ export async function deploy(project: ProjectInfo, options: DeployOptions): Prom
  * It is a warning rather than a prompt because uploading development values is
  * often exactly the wrong thing to do, which is why `env push` is explicit.
  */
-function warnAboutMissingRuntimeEnv(project: ProjectInfo, existing: EnvRecord[], appExists: boolean): void {
+function warnAboutMissingRuntimeEnv(project: ProjectInfo, existing: EnvRecord[], appExists: boolean, preview: string | null): void {
   if (project.envFiles.length === 0) return
   if (appExists && existing.length > 0) return
 
   warn(
-    `This project has ${project.envFiles.join(', ')}, but the app will have no runtime environment. ` +
+    `This project has ${project.envFiles.join(', ')}, but the ${preview === null ? 'app' : 'preview'} will have no runtime environment. ` +
       'Values Next.js inlines at build time still work; anything read at request time will be undefined. ' +
-      'Run `nextship env push` if those values belong in production.'
+      (preview === null
+        ? 'Run `nextship env push` if those values belong in production.'
+        : `Run \`nextship env push --preview ${preview}\` with values meant for a preview, never production's database.`)
   )
 }
 
diff --git a/packages/cli/src/destroy.ts b/packages/cli/src/destroy.ts
index 80f7b08..27f732c 100644
--- a/packages/cli/src/destroy.ts
+++ b/packages/cli/src/destroy.ts
@@ -24,6 +24,7 @@ import { NextshipError } from './errors.js'
 import type { ProjectInfo } from './detect.js'
 import { readConfig, writeConfig } from './config.js'
 import { ownedApp } from './owned-app.js'
+import { selectedPreview } from './preview.js'
 import { orderForDeletion } from './images.js'
 import { detail, ok, step, warn } from './util/log.js'
 
@@ -79,7 +80,8 @@ export async function destroy(project: ProjectInfo, options: DestroyOptions): Pr
 
   if (!options.confirmed) {
     ok('This was a plan only. Nothing was destroyed.')
-    detail(`Run \`nextship destroy ${app.name} --yes\` to execute it.`)
+    const preview = selectedPreview()
+    detail(`Run \`nextship destroy ${app.name}${preview === null ? '' : ` --preview ${preview}`} --yes\` to execute it.`)
     return
   }
 
@@ -89,8 +91,9 @@ export async function destroy(project: ProjectInfo, options: DestroyOptions): Pr
 
   // Written before anything else can fail, so a later error cannot leave the
   // project pointing at an app that is already gone, which every command would
-  // then refuse to act on.
-  if (config) {
+  // then refuse to act on. A preview is not in nextship.json, and the app it
+  // previews still exists.
+  if (config && selectedPreview() === null) {
     await writeConfig(project.root, { ...config, appId: undefined })
     detail('nextship.json no longer records an app, so `nextship deploy` will create a new one')
   }
diff --git a/packages/cli/src/images.test.ts b/packages/cli/src/images.test.ts
index ca8d000..5563457 100644
--- a/packages/cli/src/images.test.ts
+++ b/packages/cli/src/images.test.ts
@@ -33,7 +33,7 @@ const registry = (...tags: Array<[string, number]>): ImageRecord[] =>
 
 test('the image a live tag points to is never removed, though the API reports it untagged', () => {
   const manifests = registry(['new', 9], ['old', 8])
-  const plan = planPrune(manifests, 'new', 1)
+  const plan = planPrune(manifests, ['new'], 1)
 
   const removed = new Set(plan.remove.map((m) => m.id))
   assert.ok(!removed.has('sha256:image-new'), 'this is the image the app is running')
@@ -42,7 +42,7 @@ test('the image a live tag points to is never removed, though the API reports it
 })
 
 test('an image no retained tag can reach is removed, along with its index', () => {
-  const plan = planPrune(registry(['new', 9], ['old', 8]), 'new', 1)
+  const plan = planPrune(registry(['new', 9], ['old', 8]), ['new'], 1)
 
   assert.deepEqual(
     plan.remove.map((m) => m.id).sort(),
@@ -54,7 +54,7 @@ test('an image no retained tag can reach is removed, along with its index', () =
 
 test('the deployed image is kept even when it falls outside the limit', () => {
   // 'old' is the oldest and would be dropped on age alone, but it is running.
-  const plan = planPrune(registry(['new', 9], ['mid', 8], ['old', 7]), 'old', 1)
+  const plan = planPrune(registry(['new', 9], ['mid', 8], ['old', 7]), ['old'], 1)
 
   const removed = new Set(plan.remove.map((m) => m.id))
   assert.ok(!removed.has('sha256:image-old'), 'pruning the running image breaks the next restart')
@@ -62,14 +62,14 @@ test('the deployed image is kept even when it falls outside the limit', () => {
 })
 
 test('reclaimable size counts the images removed, not the tiny index alone', () => {
-  const plan = planPrune(registry(['new', 9], ['old', 8]), 'new', 1)
+  const plan = planPrune(registry(['new', 9], ['old', 8]), ['new'], 1)
 
   assert.ok(plan.reclaimableBytes > 181 * MIB, 'a figure near zero would be the index only')
   assert.ok(plan.reclaimableBytes < 183 * MIB)
 })
 
 test('nothing is removed when everything is still reachable', () => {
-  const plan = planPrune(registry(['new', 9], ['old', 8]), 'new', 5)
+  const plan = planPrune(registry(['new', 9], ['old', 8]), ['new'], 5)
 
   assert.deepEqual(plan.remove, [])
   assert.equal(plan.reclaimableBytes, 0)
@@ -82,7 +82,7 @@ test('a manifest shared by two retained indexes survives', () => {
     { id: 'sha256:shared', tags: [], children: [], sizeBytes: 182 * MIB, updatedAt: '2026-09-08T00:00:00Z' },
   ]
 
-  const plan = planPrune(shared, 'a', 1)
+  const plan = planPrune(shared, ['a'], 1)
   const removed = new Set(plan.remove.map((m) => m.id))
 
   assert.ok(removed.has('sha256:index-b'), 'the unreachable index goes')
@@ -97,7 +97,7 @@ test('an orphan left by an earlier tag-only deletion is collected', () => {
     { id: 'sha256:image-orphan', tags: [], children: [], sizeBytes: 182 * MIB, updatedAt: '2026-09-07T00:00:00Z' },
   ]
 
-  const plan = planPrune(manifests, 'live', 5)
+  const plan = planPrune(manifests, ['live'], 5)
 
   assert.deepEqual(
     plan.remove.map((m) => m.id).sort(),
@@ -107,20 +107,20 @@ test('an orphan left by an earlier tag-only deletion is collected', () => {
 })
 
 test('an unknown deployed tag still prunes by age rather than refusing', () => {
-  const plan = planPrune(registry(['c', 9], ['b', 8], ['a', 7]), null, 1)
+  const plan = planPrune(registry(['c', 9], ['b', 8], ['a', 7]), [], 1)
 
   assert.deepEqual(plan.keepTags, ['c'])
   assert.deepEqual(plan.removeTags.sort(), ['a', 'b'])
 })
 
 test('an empty repository plans nothing', () => {
-  assert.deepEqual(planPrune([], null, 5), { keepTags: [], removeTags: [], remove: [], reclaimableBytes: 0 })
+  assert.deepEqual(planPrune([], [], 5), { keepTags: [], removeTags: [], remove: [], reclaimableBytes: 0 })
 })
 
 test('keeping zero images is refused, because rollback would have no target', () => {
   for (const bad of [0, -1, 1.5, Number.NaN]) {
     assert.throws(
-      () => planPrune(registry(['a', 9]), 'a', bad),
+      () => planPrune(registry(['a', 9]), ['a'], bad),
       (error: unknown) => {
         assert.ok(error instanceof NextshipError)
         assert.match(error.message, /at least 1/)
@@ -172,3 +172,12 @@ test('ordering emits everything even for a malformed graph', () => {
 
   assert.equal(orderForDeletion(cyclic).length, 2, 'a bad graph must not hang the command')
 })
+
+// On a server the deployment a newer one replaced keeps running for an hour, and
+// Docker refuses to remove an image a running container uses, so a prune that
+// planned to would fail half way.
+test('every tag in use is kept, the live one and the one still running for older tabs', () => {
+  const plan = planPrune(registry(['c', 9], ['b', 8], ['a', 7]), ['c', 'a'], 1)
+  assert.deepEqual(plan.keepTags.sort(), ['a', 'c'])
+  assert.deepEqual(plan.removeTags, ['b'])
+})
diff --git a/packages/cli/src/images.ts b/packages/cli/src/images.ts
index a76a691..d04bf14 100644
--- a/packages/cli/src/images.ts
+++ b/packages/cli/src/images.ts
@@ -31,7 +31,7 @@ import { NextshipError } from './errors.js'
 import type { ProjectInfo } from './detect.js'
 import { readConfig } from './config.js'
 import { ownedApp } from './owned-app.js'
-import type { ImageRecord } from './targets/target.js'
+import type { DeploymentRecord, ImageRecord } from './targets/target.js'
 import { detail, ok, step, warn } from './util/log.js'
 
 /**
@@ -107,12 +107,13 @@ export function orderForDeletion(remove: ImageRecord[]): ImageRecord[] {
 /**
  * Decides what goes.
  *
- * The live tag is retained whatever `keep` says. Pruning the image a running
- * deployment was created from leaves an app that runs until something
+ * Tags in use are retained whatever `keep` says: the live one, and on a server the
+ * one still running for tabs opened before the last deployment. Pruning the image a
+ * running deployment was created from leaves an app that runs until something
  * reschedules it and then cannot start, a failure that appears hours later and
- * looks nothing like its cause.
+ * looks nothing like its cause, and on a server Docker refuses to remove it at all.
  */
-export function planPrune(manifests: ImageRecord[], live: string | null, keep: number): PrunePlan {
+export function planPrune(manifests: ImageRecord[], inUse: string[], keep: number): PrunePlan {
   if (!Number.isInteger(keep) || keep < 1) {
     throw new NextshipError(
       `--keep must be a whole number of at least 1, not "${keep}".`,
@@ -125,8 +126,8 @@ export function planPrune(manifests: ImageRecord[], live: string | null, keep: n
   const dropped: ImageRecord[] = []
 
   for (const manifest of tagged) {
-    const isLive = live !== null && manifest.tags.includes(live)
-    if (isLive || roots.length < keep) roots.push(manifest)
+    const used = manifest.tags.some((tag) => inUse.includes(tag))
+    if (used || roots.length < keep) roots.push(manifest)
     else dropped.push(manifest)
   }
 
@@ -178,16 +179,24 @@ async function repository(project: ProjectInfo): Promise {
  * The tag the app is deployed from, read from the deployment the target reports
  * as live rather than from any one platform's spec.
  */
-async function liveTag(app: { target: { deployments: (id: string) => Promise> }; appId: string }): Promise {
+/** The live deployment's tag, and the tag still running for older tabs, if any. */
+async function tagsInUse(app: { target: { deployments: (id: string) => Promise }; appId: string }): Promise<{ live: string | null; kept: string | null }> {
   const deployments = await app.target.deployments(app.appId)
-  return deployments.find((entry) => entry.live)?.imageTag ?? null
+  const now = Date.now()
+  return {
+    live: deployments.find((entry) => entry.live)?.imageTag ?? null,
+    kept: deployments.find((entry) => !entry.live && entry.keptUntil && Date.parse(entry.keptUntil) > now)?.imageTag ?? null,
+  }
 }
 
+const inUseList = (tags: { live: string | null; kept: string | null }): string[] =>
+  [tags.live, tags.kept].filter((tag): tag is string => tag !== null)
+
 /** Lists the images in this project's repository and what each one is for. */
 export async function listImages(project: ProjectInfo): Promise {
   const app = await ownedApp(project)
   const repo = await repository(project)
-  const live = await liveTag(app)
+  const inUse = await tagsInUse(app)
   const manifests = await app.target.images(repo)
   const tagged = manifests.filter((manifest) => manifest.tags.length > 0)
 
@@ -195,14 +204,19 @@ export async function listImages(project: ProjectInfo): Promise {
   if (tagged.length === 0) detail('No tagged images. Run `nextship deploy` to push one.')
 
   for (const manifest of tagged) {
-    const role = live !== null && manifest.tags.includes(live) ? 'deployed now' : 'kept for rollback'
+    const role =
+      inUse.live !== null && manifest.tags.includes(inUse.live)
+        ? 'deployed now'
+        : inUse.kept !== null && manifest.tags.includes(inUse.kept)
+          ? 'kept running for tabs opened before the last deployment'
+          : 'kept for rollback'
     detail(`${manifest.tags.join(', ')}  ${manifest.updatedAt}  ${role}`)
   }
 
   // Untagged manifests are normal: they are the platform images a tagged index
   // points at. Only those no tag can reach are waste, so they are counted rather
   // than listed, and identifying them is what `prune` does.
-  const orphans = planPrune(manifests, live, Number.MAX_SAFE_INTEGER).remove
+  const orphans = planPrune(manifests, inUseList(inUse), Number.MAX_SAFE_INTEGER).remove
   if (orphans.length > 0) {
     detail(`orphaned   ${orphans.length} image(s) no tag points to, up to ${megabytes(totalBytes(orphans))}`)
   }
@@ -216,9 +230,9 @@ export async function listImages(project: ProjectInfo): Promise {
 export async function pruneImages(project: ProjectInfo, options: PruneOptions): Promise {
   const app = await ownedApp(project)
   const repo = await repository(project)
-  const live = await liveTag(app)
+  const inUse = await tagsInUse(app)
   const manifests = await app.target.images(repo)
-  const plan = planPrune(manifests, live, options.keep)
+  const plan = planPrune(manifests, inUseList(inUse), options.keep)
 
   step('Plan')
   detail(`repository ${repo}`)
@@ -235,7 +249,8 @@ export async function pruneImages(project: ProjectInfo, options: PruneOptions):
   } else {
     if (plan.removeTags.length > 0) detail(`remove     ${plan.removeTags.join(', ')}`)
     detail(`remove     ${plan.remove.length} image(s), up to ${megabytes(plan.reclaimableBytes)}`)
-    detail(`deployed   ${live ?? 'unknown'}, never removed`)
+    detail(`deployed   ${inUse.live ?? 'unknown'}, never removed`)
+    if (inUse.kept) detail(`kept       ${inUse.kept}, running for tabs opened before the last deployment, never removed`)
     // Only when a tagged deployment is going. Removing orphans costs nothing
     // that could be rolled back to, and warning about it either way trains
     // people to ignore the warning that matters.
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 15094f8..ecf09dc 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -31,6 +31,7 @@ import { DEFAULT_REGION } from './targets/digitalocean-target.js'
 import { NextshipError } from './errors.js'
 import { DEFAULT_PLATFORM } from './image/dockerfile.js'
 import type { BuildPlacement } from './docker.js'
+import { takePreview } from './preview.js'
 import { VERSION } from './version.js'
 import { detail, fail, ok, step, warn } from './util/log.js'
 
@@ -119,6 +120,13 @@ Server add and server move options
   --no-swap           Do not add a swap file on a server under 4 GB of RAM
   --no-ssh-hardening  Leave password logins and root login as they are
 
+Preview options, vm target
+  --preview     Act on the preview  instead of the app: a separate app on
+                      the same server named -, with its own env and domains.
+                      Taken by deploy, rollback, logs, env, domain, images and destroy.
+                      \`deploy --preview \` creates it; \`destroy -
+                      --preview \` removes it
+
 Domain options
   --yes               Execute the plan. Without it, domain only prints the plan
   --primary           Make the domain the app's main address rather than an alias
@@ -161,6 +169,7 @@ async function main(argv: string[]): Promise {
     return
   }
 
+  const rest = takePreview(command, argv.slice(1))
   switch (command) {
     case 'detect':
       return runDetect()
@@ -173,21 +182,21 @@ async function main(argv: string[]): Promise {
     case 'doctor':
       return runDoctor()
     case 'deploy':
-      return runDeploy(argv.slice(1))
+      return runDeploy(rest)
     case 'rollback':
-      return runRollback(argv.slice(1))
+      return runRollback(rest)
     case 'logs':
-      return runLogs(argv.slice(1))
+      return runLogs(rest)
     case 'env':
-      return runEnv(argv.slice(1))
+      return runEnv(rest)
     case 'domain':
-      return runDomain(argv.slice(1))
+      return runDomain(rest)
     case 'images':
-      return runImages(argv.slice(1))
+      return runImages(rest)
     case 'destroy':
-      return runDestroy(argv.slice(1))
+      return runDestroy(rest)
     case 'server':
-      return runServer(argv.slice(1))
+      return runServer(rest)
     default:
       throw new NextshipError(`Unknown command \`${command}\`.`, 'Run `nextship --help` to see the available commands.')
   }
diff --git a/packages/cli/src/owned-app.ts b/packages/cli/src/owned-app.ts
index c7e51fb..2cb0dba 100644
--- a/packages/cli/src/owned-app.ts
+++ b/packages/cli/src/owned-app.ts
@@ -23,6 +23,7 @@ import { DigitalOceanTarget } from './targets/digitalocean-target.js'
 import type { Target } from './targets/target.js'
 import { VmTarget } from './targets/vm/vm-target.js'
 import { docsUrl } from './links.js'
+import { previewAppName, selectedPreview } from './preview.js'
 
 export interface OwnedApp {
   target: Target
@@ -100,7 +101,12 @@ export async function closeTargets(): Promise {
   await Promise.all(opened.splice(0).map((driver) => driver.close()))
 }
 
-/** The app recorded for this project, refusing to guess when there is none. */
+/**
+ * The app recorded for this project, refusing to guess when there is none. With
+ * `--preview`, the preview of it: found by name on the project's server and accepted
+ * only when its record names this project's app, so a preview is never adopted by
+ * name alone either.
+ */
 export async function ownedApp(project: ProjectInfo): Promise {
   const config = await readConfig(project.root)
   if (!config?.appId) {
@@ -109,5 +115,33 @@ export async function ownedApp(project: ProjectInfo): Promise {
       'Run `nextship deploy` first. nextship only acts on apps listed in nextship.json.'
     )
   }
-  return { target: client(config), appId: config.appId, name: config.name, config }
+  const preview = selectedPreview()
+  if (preview === null) return { target: client(config), appId: config.appId, name: config.name, config }
+
+  const scoped = previewConfig(config, preview)
+  const target = client(scoped)
+  const app = (await target.listApps()).find((entry) => entry.name === scoped.name)
+  if (!app || app.previewOf !== config.appId) {
+    throw new NextshipError(
+      app ? `An app named "${scoped.name}" exists on the server, and it is not a preview of this project.` : `This project has no preview named "${preview}".`,
+      app ? 'nextship will not act on an app it does not own. Choose another preview name.' : `Create it with \`nextship deploy --preview ${preview}\`.`
+    )
+  }
+  return { target, appId: app.id, name: scoped.name, config: scoped }
+}
+
+/**
+ * The project's settings as they apply to one of its previews: the same server and
+ * build mode under the preview's own app name, with no app id, since the preview's
+ * id lives on the server and never in `nextship.json`.
+ */
+export function previewConfig(config: ProjectConfig, preview: string): ProjectConfig {
+  if (config.target !== 'vm') {
+    throw new NextshipError(
+      'Previews run on a server, and this project deploys to DigitalOcean App Platform.',
+      'Drop --preview. A preview is a second app on the same server, which App Platform has no equivalent of here.'
+    )
+  }
+  const { appId: _appId, ...rest } = config
+  return { ...rest, name: previewAppName(config.name, preview) }
 }
diff --git a/packages/cli/src/preview.test.ts b/packages/cli/src/preview.test.ts
new file mode 100644
index 0000000..0d4c61c
--- /dev/null
+++ b/packages/cli/src/preview.test.ts
@@ -0,0 +1,55 @@
+/**
+ * @nextship/cli: tests for preview deployments
+ *
+ * A preview name ends up in an app name, a container name and a hostname, and the
+ * flag scopes every step of a command to another app, so both are pinned: what a
+ * name may be, and which commands may be pointed at a preview at all.
+ *
+ * Author: Ragul D
+ * Design: ../../../docs/design.md §9.3
+ */
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { NextshipError } from './errors.js'
+import { previewConfig } from './owned-app.js'
+import { assertPreviewName, previewAppName, selectedPreview, takePreview } from './preview.js'
+import type { ProjectConfig } from './config.js'
+
+test('a preview is named after the app it previews', () => {
+  assert.equal(previewAppName('acme-web', 'pr-42'), 'acme-web-pr-42')
+})
+
+test('a preview name is refused unless an app name, a container name and a hostname all accept it', () => {
+  for (const good of ['pr-42', 'a', 'feature-login', '0']) assert.equal(assertPreviewName(good), good)
+  for (const bad of ['', 'PR-42', 'pr_42', '-pr', 'pr-', 'pr.42', 'a'.repeat(33), 'x;rm']) {
+    assert.throws(() => assertPreviewName(bad), NextshipError, bad)
+  }
+})
+
+test('--preview is taken off the arguments and scopes the command', () => {
+  assert.deepEqual(takePreview('env', ['push', '--preview', 'pr-7', '--yes']), ['push', '--yes'])
+  assert.equal(selectedPreview(), 'pr-7')
+  assert.deepEqual(takePreview('deploy', ['--yes']), ['--yes'], 'arguments without it are left as they are')
+})
+
+// A command that does not act on one app would otherwise ignore the flag and act
+// on the server, or on the project's own app, while the user believes it is scoped.
+test('--preview is refused by commands that do not act on one app, and without a name', () => {
+  assert.throws(() => takePreview('server', ['status', '--preview', 'pr-7']), /takes no --preview/)
+  assert.throws(() => takePreview('detect', ['--preview', 'pr-7']), /takes no --preview/)
+  assert.throws(() => takePreview('deploy', ['--preview']), /needs a name/)
+  assert.throws(() => takePreview('deploy', ['--preview', '--yes']), /needs a name/)
+})
+
+const server = { host: '203.0.113.10', port: 22, user: 'nextship', hostKey: 'k', arch: 'amd64' as const }
+
+test('a preview runs on the project\'s server under its own name, with no app id of its own in nextship.json', () => {
+  const config: ProjectConfig = { version: 2, target: 'vm', name: 'acme-web', appId: 'main-id', server, build: 'local' }
+  assert.deepEqual(previewConfig(config, 'pr-42'), { version: 2, target: 'vm', name: 'acme-web-pr-42', server, build: 'local' })
+})
+
+test('a preview is refused on App Platform, which has no second app on the same server', () => {
+  const config: ProjectConfig = { version: 2, target: 'digitalocean', name: 'acme-web', appId: 'main-id', region: 'blr', registry: 'r' }
+  assert.throws(() => previewConfig(config, 'pr-42'), /Previews run on a server/)
+})
diff --git a/packages/cli/src/preview.ts b/packages/cli/src/preview.ts
new file mode 100644
index 0000000..e986ded
--- /dev/null
+++ b/packages/cli/src/preview.ts
@@ -0,0 +1,65 @@
+/**
+ * @nextship/cli: preview deployments
+ *
+ * A preview is another app on the project's server, named `-`, with its
+ * own containers, env file and domains, and it answers only on the domains attached to
+ * it. What makes it this project's is recorded on the server rather than in
+ * `nextship.json`: its app record names the app it previews by id. A CI runner that
+ * checks out the repository has the committed `nextship.json` and nothing else, so a
+ * preview it deployed for one pull request has to be found again by the run that
+ * closes that pull request, from the id both runs share.
+ *
+ * `--preview ` is taken off the command line before any command parses it, and
+ * scopes the whole command to that preview. Only commands that act on one app take it.
+ *
+ * Author: Ragul D
+ * Design: ../../../docs/design.md §9.3
+ */
+
+import { NextshipError } from './errors.js'
+
+/** The commands that act on one app, and so can act on a preview instead. */
+const PREVIEW_COMMANDS = ['deploy', 'rollback', 'logs', 'env', 'domain', 'images', 'destroy']
+
+let selected: string | null = null
+
+/**
+ * Removes `--preview ` from a command's arguments and records it for the
+ * command, refusing it where it has no meaning.
+ */
+export function takePreview(command: string, argv: string[]): string[] {
+  const at = argv.indexOf('--preview')
+  if (at === -1) return argv
+  const name = argv[at + 1]
+  if (!name || name.startsWith('--')) {
+    throw new NextshipError('--preview needs a name.', 'For example `nextship deploy --preview pr-42`.')
+  }
+  if (!PREVIEW_COMMANDS.includes(command)) {
+    throw new NextshipError(`\`nextship ${command}\` does not act on one app, so it takes no --preview.`, 'Drop --preview.')
+  }
+  selected = assertPreviewName(name)
+  return [...argv.slice(0, at), ...argv.slice(at + 2)]
+}
+
+/** The preview this command acts on, or null for the project's own app. */
+export function selectedPreview(): string | null {
+  return selected
+}
+
+/**
+ * A preview name becomes part of an app name, a container name and a hostname a user
+ * may give it, so it is held to what all three accept.
+ */
+export function assertPreviewName(name: string): string {
+  if (!/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(name)) {
+    throw new NextshipError(
+      `"${name}" is not a preview name.`,
+      'Use lowercase letters, digits and hyphens, up to 32 characters, starting and ending with a letter or digit, such as pr-42.'
+    )
+  }
+  return name
+}
+
+export function previewAppName(appName: string, preview: string): string {
+  return `${appName}-${assertPreviewName(preview)}`
+}
diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts
index 5d2744e..133c7ef 100644
--- a/packages/cli/src/server.ts
+++ b/packages/cli/src/server.ts
@@ -55,6 +55,7 @@ export const SETUP_STEPS = [
   'firewall',
   'watchdog',
   'cache-guard',
+  'retire',
   'ssh-hardening',
 ] as const
 
@@ -199,17 +200,18 @@ function runtimeFile(name: string): string {
   )
 }
 
-/** The setup script with the watchdog and cache guard it installs, ready to send on stdin. */
+/** The setup script with the timer scripts it installs, ready to send on stdin. */
 async function setupInput(): Promise {
-  const [script, watchdog, cacheGuard] = await Promise.all([
+  const [script, watchdog, cacheGuard, retire] = await Promise.all([
     readFile(runtimeFile('setup.sh'), 'utf8'),
     readFile(runtimeFile('watchdog.sh'), 'utf8'),
     readFile(runtimeFile('cache-guard.sh'), 'utf8'),
+    readFile(runtimeFile('retire.sh'), 'utf8'),
   ])
   // Assignments ahead of the script rather than arguments, so the command line
   // stays short. Base64 has no characters a shell treats specially.
   const encode = (text: string) => Buffer.from(text).toString('base64')
-  return `export NEXTSHIP_WATCHDOG_B64=${encode(watchdog)}\nexport NEXTSHIP_CACHE_GUARD_B64=${encode(cacheGuard)}\n${script}`
+  return `export NEXTSHIP_WATCHDOG_B64=${encode(watchdog)}\nexport NEXTSHIP_CACHE_GUARD_B64=${encode(cacheGuard)}\nexport NEXTSHIP_RETIRE_B64=${encode(retire)}\n${script}`
 }
 
 /** The version setup.sh records on the server, read from the script this CLI ships. */
diff --git a/packages/cli/src/targets/target.ts b/packages/cli/src/targets/target.ts
index 1babd8a..edda359 100644
--- a/packages/cli/src/targets/target.ts
+++ b/packages/cli/src/targets/target.ts
@@ -29,6 +29,8 @@
 export interface AppRef {
   id: string
   name: string
+  /** On a server, for a preview: the id of the app it previews, which is what makes it that project's. */
+  previewOf?: string
 }
 
 /**
@@ -51,6 +53,11 @@ export interface DeploymentRecord {
   createdAt: string
   /** The image it runs, which is the nextship deployment id. */
   imageTag: string | null
+  /**
+   * On a server, while the deployment a newer one replaced keeps running for the
+   * tabs that loaded it: until when. Absent on targets that keep nothing running.
+   */
+  keptUntil?: string | null
 }
 
 /** How far along a domain is, normalised because every platform names these differently. */
@@ -115,6 +122,8 @@ export interface ReleaseRequest {
   memory: string | null
   /** A prerendered route the health check can poll without rendering. */
   healthPath?: string | null
+  /** A server only: set when this release creates a preview, to the id of the app it previews. */
+  previewOf?: string
 }
 
 /** Progress reporting, so a driver can narrate a wait without importing the logger. */
diff --git a/packages/cli/src/targets/vm/caddy.test.ts b/packages/cli/src/targets/vm/caddy.test.ts
index ee7e925..aeebd58 100644
--- a/packages/cli/src/targets/vm/caddy.test.ts
+++ b/packages/cli/src/targets/vm/caddy.test.ts
@@ -12,7 +12,7 @@ import { test } from 'node:test'
 import assert from 'node:assert/strict'
 import { renderSite } from './caddy.js'
 
-const base = { name: 'demo', container: 'demo-r20260915-100000-abcdef', domains: [], isDefault: false }
+const base = { name: 'demo', container: 'demo-r20260915-100000-abcdef', domains: [], isDefault: false, previous: null, preview: false }
 
 test('the default app with no domain answers plain HTTP on port 80, streaming unbuffered', () => {
   assert.equal(
@@ -60,3 +60,41 @@ test('a name that could break out of the site file is refused', () => {
   assert.throws(() => renderSite({ ...base, domains: [{ domain: 'x.com {\n}', primary: true, minimumTls: '1.2' }] }))
   assert.throws(() => renderSite({ ...base, name: 'demo {' }))
 })
+
+// Next.js names the build on every request an open tab makes: the header on
+// navigations and Server Actions, the query on JavaScript and CSS. A page load
+// names none and must get the live build.
+test('while a previous deployment runs, requests naming its build go to it first, and the rest to the live one', () => {
+  const site = renderSite({
+    ...base,
+    isDefault: true,
+    domains: [{ domain: 'app.example.com', primary: true, minimumTls: '1.2' }],
+    previous: { container: 'demo-r20260915-090000-123456', deploymentId: 'dpl-aaa-111' },
+  })
+  const route = '  @previous expression `{header.X-Deployment-Id} == "dpl-aaa-111" || {query.dpl} == "dpl-aaa-111"`'
+  assert.equal(site.split('\n').filter((line) => line === route).length, 2, 'both the address and the domain route by build')
+  assert.match(
+    site,
+    /handle @previous \{\n    reverse_proxy demo-r20260915-090000-123456:3000 demo-r20260915-100000-abcdef:3000 \{\n      lb_policy first\n      fail_duration 30s\n      flush_interval -1/
+  )
+  assert.match(site, /  handle \{\n    reverse_proxy demo-r20260915-100000-abcdef:3000 \{\n      flush_interval -1/)
+})
+
+test('a deployment id that could break out of the expression is refused', () => {
+  assert.throws(() => renderSite({ ...base, isDefault: true, previous: { container: 'demo-r1', deploymentId: 'dpl" || true || "' } }))
+})
+
+// A preview is the site under another name, so a crawler that finds it would index
+// a copy. Crawler traffic to preview URLs is also what has run up hosting bills.
+test('a preview tells crawlers not to index it, on every name it answers', () => {
+  const site = renderSite({
+    ...base,
+    preview: true,
+    domains: [
+      { domain: 'pr-42.preview.example.com', primary: true, minimumTls: '1.2' },
+      { domain: 'secure-pr-42.preview.example.com', primary: false, minimumTls: '1.3' },
+    ],
+  })
+  assert.equal(site.match(/  header X-Robots-Tag "noindex, nofollow"/g)?.length, 2)
+  assert.doesNotMatch(renderSite({ ...base, domains: [{ domain: 'app.example.com', primary: true, minimumTls: '1.2' }] }), /X-Robots-Tag/)
+})
diff --git a/packages/cli/src/targets/vm/caddy.ts b/packages/cli/src/targets/vm/caddy.ts
index da8d7ac..83c8418 100644
--- a/packages/cli/src/targets/vm/caddy.ts
+++ b/packages/cli/src/targets/vm/caddy.ts
@@ -14,12 +14,21 @@
  * refuses it, which covers the instant between Caddy moving to a new container
  * and that container accepting a connection it has not seen before.
  *
+ * For an hour after a deployment the previous one keeps running, and a request
+ * that names its build goes to it. Next.js names the build on every request an open
+ * tab makes: `x-deployment-id` on client navigations and Server Actions, and
+ * `?dpl=` on every JavaScript and CSS file. Without this, a tab opened before the
+ * deployment asks the new container for files and Server Actions that exist only
+ * in the build it loaded, and fails. A page load names no build, so it gets the
+ * new one. Once the previous container has stopped, its route fails over to the
+ * live container, which is what every request got before this existed.
+ *
  * Author: Ragul D
  * Design: ../../../../../docs/design.md §9.3
  */
 
 import { CONTAINER_PORT } from '../../image/dockerfile.js'
-import { assertAppName, assertDomain } from './ssh.js'
+import { assertAppName, assertDeploymentId, assertDomain } from './ssh.js'
 
 export interface SiteDomain {
   domain: string
@@ -34,21 +43,50 @@ interface SiteOptions {
   domains: SiteDomain[]
   /** The first app deployed on a server answers plain HTTP on its address. */
   isDefault: boolean
+  /** The deployment before the live one, while it still runs for the tabs that loaded it. */
+  previous: { container: string; deploymentId: string } | null
+  /** A preview asks crawlers not to index it: it is a copy of the site under another name. */
+  preview: boolean
 }
 
-const proxy = (container: string): string[] => [
-  `  reverse_proxy ${container}:${CONTAINER_PORT} {`,
+const proxy = (upstreams: string[], extra: string[] = []): string[] => [
+  `  reverse_proxy ${upstreams.map((container) => `${container}:${CONTAINER_PORT}`).join(' ')} {`,
+  ...extra,
   '    flush_interval -1',
   '    lb_try_duration 5s',
   '  }',
 ]
 
+const indent = (lines: string[]): string[] => lines.map((line) => `  ${line}`)
+
+function routes(options: SiteOptions): string[] {
+  const noindex = options.preview ? ['  header X-Robots-Tag "noindex, nofollow"'] : []
+  return [...noindex, ...upstreams(options)]
+}
+
+function upstreams(options: SiteOptions): string[] {
+  if (options.previous === null) return proxy([options.container])
+  const id = assertDeploymentId(options.previous.deploymentId)
+  return [
+    `  @previous expression \`{header.X-Deployment-Id} == "${id}" || {query.dpl} == "${id}"\``,
+    '  handle @previous {',
+    // `first` sends to the previous container while it answers. A failed dial marks
+    // it down for 30 s and the retry goes to the live one, so a request that names a
+    // build no longer running is served as it would be with no route at all.
+    ...indent(proxy([options.previous.container, options.container], ['    lb_policy first', '    fail_duration 30s'])),
+    '  }',
+    '  handle {',
+    ...indent(proxy([options.container])),
+    '  }',
+  ]
+}
+
 export function renderSite(options: SiteOptions): string {
   assertAppName(options.name)
   const lines = [`# Written by nextship for ${options.name}. Regenerated on every deployment; edits are overwritten.`]
 
   if (options.isDefault) {
-    lines.push('http://:80 {', ...proxy(options.container), '}')
+    lines.push('http://:80 {', ...routes(options), '}')
   }
 
   // Caddy applies a block's tls settings to every name in it, so domains are
@@ -59,7 +97,7 @@ export function renderSite(options: SiteOptions): string {
       .sort((a, b) => Number(b.primary) - Number(a.primary))
       .map((entry) => assertDomain(entry.domain))
     if (names.length === 0) continue
-    lines.push(`${names.join(', ')} {`, '  tls {', `    protocols tls${minimum}`, '  }', ...proxy(options.container), '}')
+    lines.push(`${names.join(', ')} {`, '  tls {', `    protocols tls${minimum}`, '  }', ...routes(options), '}')
   }
 
   return `${lines.join('\n')}\n`
diff --git a/packages/cli/src/targets/vm/deployments.test.ts b/packages/cli/src/targets/vm/deployments.test.ts
index 1dda956..fc12a53 100644
--- a/packages/cli/src/targets/vm/deployments.test.ts
+++ b/packages/cli/src/targets/vm/deployments.test.ts
@@ -16,6 +16,8 @@ import {
   imagesToKeep,
   newReleaseId,
   parseDeployments,
+  PREVIOUS_KEPT_MS,
+  previousServing,
   recordFailed,
   recordLive,
   recordStarted,
@@ -23,18 +25,19 @@ import {
 } from './deployments.js'
 
 const at = '2026-09-15T10:00:00.000Z'
+const now = new Date(at)
 
 test('a first deployment goes from started to live, and becomes a rollback target', () => {
   let history = recordStarted([], { id: 'r1', imageTag: 'dpl-a', cause: 'deploy', createdAt: at, healthPath: null })
-  assert.deepEqual(history[0], { id: 'r1', imageTag: 'dpl-a', cause: 'deploy', createdAt: at, healthPath: null, served: false, live: false })
-  history = recordLive(history, 'r1')
+  assert.deepEqual(history[0], { id: 'r1', imageTag: 'dpl-a', cause: 'deploy', createdAt: at, healthPath: null, served: false, live: false, keptUntil: null })
+  history = recordLive(history, 'r1', now)
   assert.equal(history[0].live, true)
   assert.equal(history[0].served, true)
 })
 
 test('an update moves live to the new deployment and keeps the old one served', () => {
-  let history = recordLive(recordStarted([], { id: 'r1', imageTag: 'dpl-a', cause: 'deploy', createdAt: at, healthPath: null }), 'r1')
-  history = recordLive(recordStarted(history, { id: 'r2', imageTag: 'dpl-b', cause: 'deploy', createdAt: at, healthPath: null }), 'r2')
+  let history = recordLive(recordStarted([], { id: 'r1', imageTag: 'dpl-a', cause: 'deploy', createdAt: at, healthPath: null }), 'r1', now)
+  history = recordLive(recordStarted(history, { id: 'r2', imageTag: 'dpl-b', cause: 'deploy', createdAt: at, healthPath: null }), 'r2', now)
   assert.deepEqual(history.map((entry) => [entry.id, entry.live, entry.served]), [['r2', true, true], ['r1', false, true]])
   assert.deepEqual(rollbackCandidates(history, 'r2').map((entry) => entry.id), ['r1'])
 })
@@ -42,7 +45,7 @@ test('an update moves live to the new deployment and keeps the old one served',
 // A failed deployment must not take live away from the one still serving, or
 // rollback would report nothing live while traffic is flowing.
 test('a failed health check leaves the previous deployment live and is never a rollback target', () => {
-  let history = recordLive(recordStarted([], { id: 'r1', imageTag: 'dpl-a', cause: 'deploy', createdAt: at, healthPath: null }), 'r1')
+  let history = recordLive(recordStarted([], { id: 'r1', imageTag: 'dpl-a', cause: 'deploy', createdAt: at, healthPath: null }), 'r1', now)
   history = recordFailed(recordStarted(history, { id: 'r2', imageTag: 'dpl-b', cause: 'deploy', createdAt: at, healthPath: null }), 'r2', 'failed health check')
   assert.equal(history.find((entry) => entry.live)?.id, 'r1')
   assert.equal(history[0].cause, 'deploy, failed health check')
@@ -50,31 +53,31 @@ test('a failed health check leaves the previous deployment live and is never a r
 })
 
 test('a rollback is a new deployment of an old image, recorded with its cause', () => {
-  let history = recordLive(recordStarted([], { id: 'r1', imageTag: 'dpl-a', cause: 'deploy', createdAt: at, healthPath: null }), 'r1')
-  history = recordLive(recordStarted(history, { id: 'r2', imageTag: 'dpl-b', cause: 'deploy', createdAt: at, healthPath: null }), 'r2')
-  history = recordLive(recordStarted(history, { id: 'r3', imageTag: 'dpl-a', cause: 'rollback to r1', createdAt: at, healthPath: null }), 'r3')
+  let history = recordLive(recordStarted([], { id: 'r1', imageTag: 'dpl-a', cause: 'deploy', createdAt: at, healthPath: null }), 'r1', now)
+  history = recordLive(recordStarted(history, { id: 'r2', imageTag: 'dpl-b', cause: 'deploy', createdAt: at, healthPath: null }), 'r2', now)
+  history = recordLive(recordStarted(history, { id: 'r3', imageTag: 'dpl-a', cause: 'rollback to r1', createdAt: at, healthPath: null }), 'r3', now)
   assert.deepEqual(history.map((entry) => entry.live), [true, false, false])
   assert.equal(history[0].imageTag, 'dpl-a')
 })
 
 test('marking an unknown deployment live is a defect, not a silent no-op', () => {
-  assert.throws(() => recordLive([], 'r9'), /not in the history/)
+  assert.throws(() => recordLive([], 'r9', now), /not in the history/)
 })
 
 test('the history round-trips, and a damaged file is refused rather than replaced', () => {
-  const history = recordLive(recordStarted([], { id: 'r1', imageTag: 'dpl-a', cause: 'deploy', createdAt: at, healthPath: null }), 'r1')
+  const history = recordLive(recordStarted([], { id: 'r1', imageTag: 'dpl-a', cause: 'deploy', createdAt: at, healthPath: null }), 'r1', now)
   assert.deepEqual(parseDeployments(serializeDeployments(history)), history)
   assert.deepEqual(parseDeployments(''), [])
   assert.throws(() => parseDeployments('{'), /not valid JSON/)
   assert.throws(() => parseDeployments('{}'), /not a list/)
   assert.throws(() => parseDeployments('[{"id":"r1"}]'), /unreadable entry at position 1/)
-  // A history written before healthPath was recorded still reads.
-  const { healthPath: _omitted, ...older } = history[0]
+  // A history written before healthPath and keptUntil were recorded still reads.
+  const { healthPath: _health, keptUntil: _kept, ...older } = history[0]
   assert.equal(parseDeployments(JSON.stringify([older]))[0].healthPath, null)
+  assert.equal(parseDeployments(JSON.stringify([older]))[0].keptUntil, null)
 })
 
 test('release ids are unique per release even for the same image, and valid container name suffixes', () => {
-  const now = new Date(at)
   const a = newReleaseId(now)
   assert.notEqual(a, newReleaseId(now))
   assert.match(a, /^r20260915-100000-[0-9a-f]{6}$/)
@@ -83,13 +86,51 @@ test('release ids are unique per release even for the same image, and valid cont
 test('images kept are the live one and the newest served ones, never a failed one', () => {
   let history: ReturnType = []
   for (const [id, tag] of [['r1', 'a'], ['r2', 'b'], ['r3', 'c'], ['r4', 'd']]) {
-    history = recordLive(recordStarted(history, { id, imageTag: tag, cause: 'deploy', createdAt: at, healthPath: null }), id)
+    history = recordLive(recordStarted(history, { id, imageTag: tag, cause: 'deploy', createdAt: at, healthPath: null }), id, now)
   }
   history = recordFailed(recordStarted(history, { id: 'r5', imageTag: 'broken', cause: 'deploy', createdAt: at, healthPath: null }), 'r5', 'failed')
   assert.deepEqual([...imagesToKeep(history, 2)].sort(), ['c', 'd'])
 
-  // Rolled back to the oldest: it is live, so it is kept even beyond the count.
-  history = recordLive(recordStarted(history, { id: 'r6', imageTag: 'a', cause: 'rollback to r1', createdAt: at, healthPath: null }), 'r6')
-  assert.deepEqual([...imagesToKeep(history, 1)], ['a'])
+  // Rolled back to the oldest: it is live, so it is kept even beyond the count, and
+  // so is the one it replaced, whose container keeps running for its tabs.
+  history = recordLive(recordStarted(history, { id: 'r6', imageTag: 'a', cause: 'rollback to r1', createdAt: at, healthPath: null }), 'r6', now)
+  assert.deepEqual([...imagesToKeep(history, 1)].sort(), ['a', 'd'])
   assert.ok(!imagesToKeep(history, 10).has('broken'))
 })
+
+const start = (history: ReturnType, id: string, imageTag: string) =>
+  recordStarted(history, { id, imageTag, cause: 'deploy', createdAt: at, healthPath: null })
+
+// The defect this prevents: a tab loaded before a deployment asking the new
+// container for its build's files and Server Actions, which only the old one has.
+test('a new build keeps the one it replaced for an hour, and no other', () => {
+  let history = recordLive(start([], 'r1', 'dpl-a'), 'r1', now)
+  history = recordLive(start(history, 'r2', 'dpl-b'), 'r2', now)
+  assert.equal(previousServing(history, now)?.id, 'r1')
+  assert.equal(history.find((entry) => entry.id === 'r1')?.keptUntil, new Date(now.getTime() + PREVIOUS_KEPT_MS).toISOString())
+  history = recordLive(start(history, 'r3', 'dpl-c'), 'r3', now)
+  assert.deepEqual(history.filter((entry) => entry.keptUntil !== null).map((entry) => entry.id), ['r2'])
+  assert.equal(previousServing(history, new Date(now.getTime() + PREVIOUS_KEPT_MS + 1)), null, 'nothing is kept once its hour is over')
+})
+
+// Both containers would answer to one deployment id, so routing by it would send
+// every tab, new ones too, to the container being replaced.
+test('the same build started again is not kept, and does not end a keep already running', () => {
+  let history = recordLive(start([], 'r1', 'dpl-a'), 'r1', now)
+  history = recordLive(start(history, 'r2', 'dpl-b'), 'r2', now)
+  const later = new Date(now.getTime() + 60_000)
+  history = recordLive(start(history, 'r3', 'dpl-b'), 'r3', later)
+  assert.equal(previousServing(history, later)?.id, 'r1')
+  assert.equal(history.find((entry) => entry.id === 'r1')?.keptUntil, new Date(now.getTime() + PREVIOUS_KEPT_MS).toISOString())
+})
+
+// An instant rollback: the kept deployment becomes live again without a new
+// container, and the one it replaces is kept in its place.
+test('going back to the kept deployment makes it live and keeps the one it replaces', () => {
+  let history = recordLive(start([], 'r1', 'dpl-a'), 'r1', now)
+  history = recordLive(start(history, 'r2', 'dpl-b'), 'r2', now)
+  history = recordLive(history, 'r1', now)
+  assert.equal(history.find((entry) => entry.live)?.id, 'r1')
+  assert.equal(previousServing(history, now)?.id, 'r2')
+  assert.equal(history.filter((entry) => entry.keptUntil !== null).length, 1)
+})
diff --git a/packages/cli/src/targets/vm/deployments.ts b/packages/cli/src/targets/vm/deployments.ts
index 8ba6e87..347cf09 100644
--- a/packages/cli/src/targets/vm/deployments.ts
+++ b/packages/cli/src/targets/vm/deployments.ts
@@ -36,8 +36,21 @@ export function newReleaseId(now: Date): string {
  */
 export interface VmDeployment extends DeploymentRecord {
   healthPath: string | null
+  /**
+   * Set on the deployment a new one replaced: its container keeps running until
+   * then, for the tabs that loaded it (`caddy.ts`). At most one entry has it.
+   */
+  keptUntil: string | null
 }
 
+/**
+ * How long the deployment before the live one keeps running. An hour covers a tab
+ * left open through a deployment for the length of an ordinary working session, at
+ * the cost of a second container's memory for that hour. A tab older than that
+ * reloads the page on its next navigation, as it would with nothing kept.
+ */
+export const PREVIOUS_KEPT_MS = 60 * 60 * 1000
+
 /** Reads the file, refusing one that is not a history rather than starting a new one over it. */
 export function parseDeployments(contents: string): VmDeployment[] {
   if (contents.trim() === '') return []
@@ -57,7 +70,8 @@ export function parseDeployments(contents: string): VmDeployment[] {
       typeof record.cause !== 'string' ||
       typeof record.createdAt !== 'string' ||
       !(typeof record.imageTag === 'string' || record.imageTag === null) ||
-      !(record.healthPath === undefined || record.healthPath === null || typeof record.healthPath === 'string')
+      !(record.healthPath === undefined || record.healthPath === null || typeof record.healthPath === 'string') ||
+      !(record.keptUntil === undefined || record.keptUntil === null || typeof record.keptUntil === 'string')
     ) {
       throw corrupt(`has an unreadable entry at position ${index + 1}`)
     }
@@ -69,6 +83,7 @@ export function parseDeployments(contents: string): VmDeployment[] {
       createdAt: record.createdAt,
       imageTag: record.imageTag,
       healthPath: record.healthPath ?? null,
+      keptUntil: record.keptUntil ?? null,
     }
   })
 }
@@ -88,19 +103,44 @@ export function recordStarted(
   deployments: VmDeployment[],
   entry: { id: string; imageTag: string; cause: string; createdAt: string; healthPath: string | null }
 ): VmDeployment[] {
-  return [{ ...entry, served: false, live: false }, ...deployments]
+  return [{ ...entry, served: false, live: false, keptUntil: null }, ...deployments]
 }
 
 /**
  * The deployment took traffic. It becomes the only live one, and stays a valid
  * rollback target from now on.
+ *
+ * The deployment it replaced is kept for PREVIOUS_KEPT_MS when it is a different
+ * build. The same build started again, by `env push` or a domain change, is not:
+ * both would answer to one deployment id, and routing by it would send every tab,
+ * new ones included, to the container being replaced. A deployment already kept
+ * stays kept until its time runs out, unless it is the build going live.
  */
-export function recordLive(deployments: VmDeployment[], id: string): VmDeployment[] {
-  if (!deployments.some((entry) => entry.id === id)) {
+export function recordLive(deployments: VmDeployment[], id: string, now: Date): VmDeployment[] {
+  const next = deployments.find((entry) => entry.id === id)
+  if (!next) {
     throw new NextshipError(`Deployment ${id} is not in the history.`, 'This is a nextship defect. Please report it.')
   }
-  return deployments.map((entry) =>
-    entry.id === id ? { ...entry, served: true, live: true } : { ...entry, live: false }
+  const replaced = deployments.find((entry) => entry.live && entry.id !== id)
+  const existing = previousServing(deployments, now)
+  let kept: { id: string; until: string } | null = null
+  if (replaced && replaced.imageTag !== next.imageTag) {
+    kept = { id: replaced.id, until: new Date(now.getTime() + PREVIOUS_KEPT_MS).toISOString() }
+  } else if (existing && existing.id !== id && existing.imageTag !== next.imageTag && existing.keptUntil) {
+    kept = { id: existing.id, until: existing.keptUntil }
+  }
+  return deployments.map((entry) => {
+    if (entry.id === id) return { ...entry, served: true, live: true, keptUntil: null }
+    return { ...entry, live: false, keptUntil: entry.id === kept?.id ? kept.until : null }
+  })
+}
+
+/** The deployment kept running for the tabs that loaded it, if its time has not run out. */
+export function previousServing(deployments: VmDeployment[], now: Date): VmDeployment | null {
+  return (
+    deployments.find(
+      (entry) => !entry.live && entry.served && entry.keptUntil !== null && Date.parse(entry.keptUntil) > now.getTime()
+    ) ?? null
   )
 }
 
@@ -121,6 +161,8 @@ export function imagesToKeep(deployments: DeploymentRecord[], keep: number): Set
   const kept = new Set()
   const live = deployments.find((entry) => entry.live)?.imageTag
   if (live) kept.add(live)
+  // Its container still runs, and Docker will not remove an image a container uses.
+  for (const entry of deployments) if (entry.keptUntil && entry.imageTag) kept.add(entry.imageTag)
   for (const entry of deployments) {
     if (kept.size >= keep) break
     if (entry.served && entry.imageTag) kept.add(entry.imageTag)
diff --git a/packages/cli/src/targets/vm/vm-target.ts b/packages/cli/src/targets/vm/vm-target.ts
index 7b2c2f3..03dc347 100644
--- a/packages/cli/src/targets/vm/vm-target.ts
+++ b/packages/cli/src/targets/vm/vm-target.ts
@@ -66,6 +66,7 @@ import {
   imagesToKeep,
   newReleaseId,
   parseDeployments,
+  previousServing,
   recordFailed,
   recordLive,
   recordStarted,
@@ -99,6 +100,8 @@ interface AppRecord {
   name: string
   createdAt: string
   domains: SiteDomain[]
+  /** Set on a preview: the id of the app it previews (`preview.ts`). */
+  previewOf?: string
 }
 
 /** What a server reports in one round trip, for plans and guards. */
@@ -464,7 +467,8 @@ export class VmTarget implements Target {
   ]
 
   readonly rollbackNotes = [
-    'the current env file is used, not the one that deployment ran with: variables changed since then keep their new values',
+    'the deployment replaced within the last hour is still running: going back to it is one Caddy switch, and it keeps the env it ran with',
+    'an older deployment is started again with the current env file, not the one it ran with: variables changed since then keep their new values',
   ]
 
   // ------------------------------------------------------------ connection
@@ -525,7 +529,7 @@ export class VmTarget implements Target {
       .filter((line) => line.trim() !== '')
       .map((line) => {
         const record = JSON.parse(line) as AppRecord
-        return { id: record.id, name: record.name }
+        return { id: record.id, name: record.name, ...(record.previewOf ? { previewOf: record.previewOf } : {}) }
       })
   }
 
@@ -758,6 +762,7 @@ export class VmTarget implements Target {
       cause: 'deploy',
       memory: request.memory,
       healthPath: request.healthPath ?? null,
+      previewOf: request.previewOf,
     })
     return { appId: started.appId, deploymentId: started.releaseId }
   }
@@ -827,12 +832,27 @@ export class VmTarget implements Target {
     return result.code === 0 ? parseDeployments(result.stdout) : []
   }
 
+  /**
+   * Writes the history, and beside it `previous`: the kept container and when it
+   * stops, as ` `, empty when none is kept. The retire
+   * timer on the server reads that line, since the server has no JSON parser, and
+   * both are written here so the one can never disagree with the other.
+   */
   private async writeDeployments(deployments: VmDeployment[]): Promise {
+    const kept = previousServing(deployments, new Date())
+    const previous = kept?.keptUntil ? `${this.name}-${kept.id} ${Math.floor(Date.parse(kept.keptUntil) / 1000)}\n` : ''
     await this.run(
       `install -m 600 /dev/stdin ${q(`${this.appDir()}/deployments.json`)}`,
       'write the deployment history',
       serializeDeployments(deployments)
     )
+    await this.run(`install -m 600 /dev/stdin ${q(`${this.appDir()}/previous`)}`, 'record when the previous deployment stops', previous)
+  }
+
+  /** The Caddy route for the kept deployment, from a history. */
+  private previousRoute(deployments: VmDeployment[], now: Date): { container: string; deploymentId: string } | null {
+    const kept = previousServing(deployments, now)
+    return kept?.imageTag ? { container: `${this.name}-${kept.id}`, deploymentId: kept.imageTag } : null
   }
 
   /**
@@ -842,7 +862,14 @@ export class VmTarget implements Target {
    */
   private async startRelease(
     appId: string | null,
-    options: { imageTag: string; cause: string; memory: string | null; healthPath: string | null; before?: () => Promise }
+    options: {
+      imageTag: string
+      cause: string
+      memory: string | null
+      healthPath: string | null
+      before?: () => Promise
+      previewOf?: string
+    }
   ): Promise<{ appId: string; releaseId: string }> {
     await this.lock()
     try {
@@ -857,7 +884,13 @@ export class VmTarget implements Target {
             'nextship will not modify an app it does not own. Rename this project, or set a different name in nextship.json.'
           )
         }
-        record = { id: randomUUID(), name: this.name, createdAt: new Date().toISOString(), domains: [] }
+        record = {
+          id: randomUUID(),
+          name: this.name,
+          createdAt: new Date().toISOString(),
+          domains: [],
+          ...(options.previewOf ? { previewOf: options.previewOf } : {}),
+        }
         await this.writeAppRecord(record)
       }
       await this.run(`touch ${q(`${this.appDir()}/env`)} && chmod 600 ${q(`${this.appDir()}/env`)}`, 'prepare the env file')
@@ -929,8 +962,10 @@ export class VmTarget implements Target {
     }
 
     const record = await this.appRecord(appId)
+    const now = new Date()
+    const history = recordLive(await this.readDeployments(), releaseId, now)
     try {
-      await this.switchSite(record, container)
+      await this.switchSite(record, container, this.previousRoute(history, now))
     } catch (error) {
       await this.discard(container, releaseId, 'was refused by Caddy', onPhase)
       throw new NextshipError(
@@ -945,7 +980,6 @@ export class VmTarget implements Target {
     // time: a CLI that dies in between would otherwise leave deployments.json
     // naming a stopped container as live, which `domain add` would then rebuild
     // the site from and point the proxy back at something that is not running.
-    const history = recordLive(await this.readDeployments(), releaseId)
     await this.writeDeployments(history)
 
     // A previous container that is restarting or paused is not serving, but it
@@ -955,7 +989,11 @@ export class VmTarget implements Target {
       `docker ps --filter label=sh.nextship.managed=true --filter ${q(`label=sh.nextship.app=${this.name}`)} --filter status=running --filter status=restarting --filter status=paused --format '{{.Names}}'`,
       'list the running containers'
     )
-    const previous = previousContainers(running, this.name, container)
+    // The deployment this one replaced keeps running for the tabs that loaded it, if
+    // it is a different build; `retire.sh` stops it when its time runs out.
+    const kept = previousServing(history, now)
+    const previous = previousContainers(running, this.name, container).filter((name) => name !== `${this.name}-${kept?.id}`)
+    if (kept) onPhase(`kept ${this.name}-${kept.id} running until ${kept.keptUntil} for tabs that loaded it`)
     if (previous.length > 0) {
       // 30 s matches the time a request is allowed to finish: Next.js exits on
       // SIGTERM once in-flight responses and after() callbacks complete.
@@ -1033,12 +1071,19 @@ export class VmTarget implements Target {
    * the previous file is put back and Caddy reloaded again, so a bad site never
    * takes the proxy down for every app on the server.
    */
-  private async switchSite(record: AppRecord, container: string): Promise {
+  private async switchSite(
+    record: AppRecord,
+    container: string,
+    previous: { container: string; deploymentId: string } | null
+  ): Promise {
     const site = renderSite({
       name: this.name,
       container,
       domains: record.domains,
-      isDefault: (await this.claimDefaultApp()) === this.name,
+      // A preview never takes the server's own address, which belongs to a real app.
+      isDefault: record.previewOf === undefined && (await this.claimDefaultApp()) === this.name,
+      previous,
+      preview: record.previewOf !== undefined,
     })
     await this.applySite(site)
   }
@@ -1184,6 +1229,7 @@ export class VmTarget implements Target {
         'It was pruned. Check out that commit and run `nextship deploy` to build it again. Nothing was changed.'
       )
     }
+    if (await this.rollbackInstantly(appId, target, onPhase)) return
     const live = (await this.readDeployments()).some((entry) => entry.live)
     const started = await this.startRelease(appId, {
       imageTag: target.imageTag,
@@ -1198,6 +1244,38 @@ export class VmTarget implements Target {
     }
   }
 
+  /**
+   * Returns to the kept deployment by pointing Caddy back at its container, which
+   * never stopped, so nothing is built or started and the switch is one reload.
+   * The deployment being left is kept in its place, so a mistaken rollback can be
+   * undone the same way. Returns false, changing nothing, when the target is not
+   * the kept deployment or its container is not running and healthy; the caller
+   * then starts the target's image again as it always did.
+   *
+   * The container runs with the env it started with, which is the env of that
+   * deployment, where a rollback that starts a new container reads the current one.
+   */
+  private async rollbackInstantly(appId: string, target: VmDeployment, onPhase: PhaseReporter): Promise {
+    const began = Date.now()
+    const container = `${this.name}-${target.id}`
+    if (previousServing(await this.readDeployments(), new Date())?.id !== target.id) return false
+    const state = await this.exec(`docker inspect -f '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' ${q(container)}`)
+    if (state.code !== 0 || state.stdout.trim() !== 'running healthy') return false
+
+    await this.lock()
+    try {
+      const now = new Date()
+      const history = recordLive(await this.readDeployments(), target.id, now)
+      await this.switchSite(await this.appRecord(appId), container, this.previousRoute(history, now))
+      await this.writeDeployments(history)
+      onPhase(`Caddy now sends traffic to ${container}, which was still running, in ${Date.now() - began} ms`)
+      onPhase('it runs with the env it was deployed with, not the current env file')
+    } finally {
+      await this.unlock()
+    }
+    return true
+  }
+
   /** Starts the live image again, for a change that needs a new container but no new build. */
   private async redeploy(appId: string, cause: string, before: () => Promise): Promise {
     const history = await this.readDeployments()
@@ -1309,7 +1387,8 @@ export class VmTarget implements Target {
     try {
       const record = await this.appRecord(appId)
       const updated = { ...record, domains: change(record.domains) }
-      const live = (await this.readDeployments()).find((entry) => entry.live)
+      const history = await this.readDeployments()
+      const live = history.find((entry) => entry.live)
       if (live) {
         const facts = await this.facts()
         const site = renderSite({
@@ -1317,6 +1396,8 @@ export class VmTarget implements Target {
           container: `${this.name}-${live.id}`,
           domains: updated.domains,
           isDefault: facts.defaultApp === this.name,
+          previous: this.previousRoute(history, new Date()),
+          preview: record.previewOf !== undefined,
         })
         // The site is validated and live before the record says so, so a domain
         // Caddy refused is never recorded as attached.
diff --git a/site/content/docs/vm.mdx b/site/content/docs/vm.mdx
index a891319..4efb73f 100644
--- a/site/content/docs/vm.mdx
+++ b/site/content/docs/vm.mdx
@@ -33,8 +33,9 @@ The first command changes nothing. It shows the server's host key fingerprint, w
 compare with the one your provider shows, and every setup step with what it would change.
 The second installs Docker if needed, creates a `nextship` user and proves a login as it,
 runs Caddy on ports 80 and 443, limits the journal, enables security updates and ufw,
-installs a watchdog for containers that stay unhealthy and a cache guard that keeps what each
-app writes at runtime under a limit, and turns off SSH password logins last. From then on the host key is pinned in `nextship.json` and a server presenting any
+installs a watchdog for containers that stay unhealthy, a cache guard that keeps what each
+app writes at runtime under a limit and a timer that stops a replaced deployment after its
+hour, and turns off SSH password logins last. From then on the host key is pinned in `nextship.json` and a server presenting any
 other key is refused.
 
 `--no-firewall`, `--no-auto-updates`, `--no-swap` and `--no-ssh-hardening` opt out of those
@@ -71,7 +72,8 @@ nextship domain add app.example.com --yes
 | Cache after a restart | Starts cold | Regenerated pages and optimized images kept in volumes |
 | Logs of a replaced deployment | Gone | `nextship logs --deployment ` reads the journal |
 | CDN | In front, keeps static pages | None unless you add one |
-| Rollback | Restores the old spec, env included | Starts the old image with the current env file |
+| Rollback | Restores the old spec, env included | Within an hour, switches back to the replaced deployment, still running with its env. Older, starts the old image with the current env file |
+| A tab open across a deployment | Loads the new build on its next navigation | Keeps working for an hour: requests naming its build reach it |
 | Server Actions key | In a local file | On the server, shared by every machine that deploys |
 | Apps | One per App Platform app | Many per server, each with its own domains |
 | Failover | Managed | None: one server |
@@ -92,6 +94,15 @@ Two things on the server exist nowhere else: `/etc/nextship/apps//env` and
 `/etc/nextship/apps//secrets`. Keep a copy of both. The `nextship` user is in the docker
 group, which is root-equivalent, so treat its keys as root's.
 
+A tab opened before a deployment keeps working after it: the deployment a new build replaces
+keeps running for an hour and gets every request that names its build. A rollback within that
+hour switches back to it at once.
+
+`nextship deploy --preview pr-42` deploys a preview: a second app on the same server with its
+own env file and domains, which asks crawlers not to index it. A workflow that deploys one per
+pull request is in the
+[server guide](https://github.com/gowtham472/nextship/blob/main/docs/vm.md#a-preview-per-pull-request).
+
 Each app's container is read-only apart from `.next` and a 64 MB `/tmp` that cannot run a
 binary, with no Linux capabilities and a limit of 512 processes. Code an attacker runs through
 a flaw in your app can neither rewrite it nor leave a program behind. An app that saves

From 283f26ff1c7cc03bfac1a16e91a4d4c9f2a7747f Mon Sep 17 00:00:00 2001
From: ragul2105 
Date: Tue, 29 Sep 2026 12:45:49 +0530
Subject: [PATCH 05/11] perf(vm): switch to a new container as soon as it is
 healthy

Timed stage by stage, a warm deployment of a one-line change took 16.6 s
and 4.1 s of it was waiting for a container that was ready at once to be
seen as healthy: Docker's first check came two seconds after start and
the deploy polled every two seconds. Both are half a second now. Three
warm deployments then took 15.0, 13.5 and 13.3 s with a 1.1 s wait. Most
of what remains is next build, so the roadmap records that under ten
seconds is not reachable by nextship alone.

e2e.sh passes 25 of 25 with the shorter intervals, including zero
downtime across a deployment.

Co-Authored-By: Claude Opus 5.5 
---
 CHANGELOG.md                                  |  4 ++++
 docs/design.md                                |  9 +++++++++
 docs/roadmap.md                               |  2 +-
 packages/cli/src/targets/vm/vm-target.test.ts |  2 +-
 packages/cli/src/targets/vm/vm-target.ts      | 12 ++++++++----
 5 files changed, 23 insertions(+), 6 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6628cbc..c8ee757 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,10 @@ All notable changes to this repository. Attribution rules: `AGENTS.md` §1.1.
 
 ### Changed
 
+- **A deployment to a server switches about three seconds sooner.** A container that was
+  ready at once waited 4.1 s to be seen as healthy, a quarter of a 16.6 s warm deployment,
+  because Docker's first check and the deploy's polling were both two seconds apart. Both
+  are half a second now; three warm deployments took 13.3 to 15.0 s. (Ragul D)
 - **An app on a server runs in a hardened container.** Its root filesystem is read-only
   apart from the two `.next` volumes and a 64 MB `/tmp` that is emptied on restart and
   cannot run a binary; every Linux capability is dropped, privileges cannot be gained,
diff --git a/docs/design.md b/docs/design.md
index a24eced..857f5ed 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -707,6 +707,15 @@ failed its deployment while 123 of 123 requests to the previous one returned 200
 `rollback --yes` moved Caddy to a new container of the previous image. A warm remote
 build and deployment took 16.5 s; `--build local` from an M3 Max took 60 s.
 
+A warm deployment of a one-line change, timed stage by stage on the stand-in (Docker Desktop
+on an M3 Max): planning 0.6 s, `next build` in the builder 7.7 to 9.1 s, packaging the runtime
+image 3.4 s, of which starting the second `docker build` is most and copying the app 0.2 s,
+starting the container 0.4 s, waiting for it to be healthy, and switching Caddy 0.5 s. The wait
+was 4.1 s of a 16.6 s deployment for a container ready at once, because Docker's first check
+came two seconds after start and the deploy polled every two seconds; both are half a second
+now, and three deployments took 15.0, 13.5 and 13.3 s with a 1.1 s wait. What remains is
+mostly `next build`, which nextship does not own, so no deployment time is promised.
+
 #### Runtime writes (Implemented)
 
 Measured with `docker diff` in a deployed container after exercising each feature:
diff --git a/docs/roadmap.md b/docs/roadmap.md
index ce4546f..8e281b7 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -282,7 +282,7 @@ first are the ones a new user meets in their first ten minutes:
 | 4 | **Skew protection and instant rollback.** The previous deployment's container keeps running for an hour, Caddy sends each request to the build the page was loaded from, and a rollback within that hour only reloads Caddy | **Done.** See `design.md` §9.3, Release sequence |
 | 5 | **The compatibility suite nightly against the newest canary, published on the site** | **Built, not yet run.** `conformance.yml` runs nightly and publishes to the `evidence` branch, which the evidence page reads. Verified once the first scheduled run publishes, which needs this merged to `main` |
 | 6 | **A preview deployment per pull request on the same server**, removed when the pull request closes | **Done**, `--preview `, verified end to end. The pull request workflow in `vm.md` §6 has not run in GitHub Actions |
-| 7 | **Deploy time measured stage by stage**, then shortened where nextship owns the time | A warm deployment to a server takes 16.5 s, and most of it is `next build`. No target time is promised until the stages are measured |
+| 7 | **Deploy time measured stage by stage**, then shortened where nextship owns the time | **Done.** The health wait was 4.1 s of a 16.6 s warm deployment and is 1.1 s now; three deployments took 13.3 to 15.0 s, and 7.7 s or more of that is `next build`. See `design.md` §9.3, Release sequence. Under ten seconds is not reachable without a faster `next build`, so it is not promised |
 | 8 | **Build once, promote to another environment** with its own `NEXT_PUBLIC_*` values | **Concluded no.** See "Deliberately not built" |
 | 9 | **A migration plan from Vercel**: domains, cron jobs, routing rules and the names of env vars | Vercel's API never returns a sensitive env var's value, so the plan asks for those rather than importing blanks, for the reason `env pull` is not built |
 | 10 | **Peak build memory in the build result**, and a warning near the limit | `server add` already adds swap below 4 GB, and remote builds are refused below 2 GB |
diff --git a/packages/cli/src/targets/vm/vm-target.test.ts b/packages/cli/src/targets/vm/vm-target.test.ts
index 0a211c3..668aaf9 100644
--- a/packages/cli/src/targets/vm/vm-target.test.ts
+++ b/packages/cli/src/targets/vm/vm-target.test.ts
@@ -102,7 +102,7 @@ test('the health check matches the image, with a fast start interval only where
   const args = run()
   assert.deepEqual(value(args, '--health-interval'), ['30s'])
   assert.deepEqual(value(args, '--health-retries'), ['3'])
-  assert.deepEqual(value(args, '--health-start-interval'), ['2s'])
+  assert.deepEqual(value(args, '--health-start-interval'), ['500ms'])
   assert.deepEqual(value(run({ dockerMajor: 24 }), '--health-start-interval'), [])
 })
 
diff --git a/packages/cli/src/targets/vm/vm-target.ts b/packages/cli/src/targets/vm/vm-target.ts
index 03dc347..bde9e24 100644
--- a/packages/cli/src/targets/vm/vm-target.ts
+++ b/packages/cli/src/targets/vm/vm-target.ts
@@ -83,7 +83,8 @@ const DEFAULT_APP_FILE = `${ETC}/default-app`
 
 /** How long a new container has to report healthy before the deployment fails. */
 const HEALTH_TIMEOUT_MS = 5 * 60 * 1000
-const HEALTH_POLL_MS = 2000
+/** Matches the start interval in `runArguments`, so the switch follows the first passing check. */
+const HEALTH_POLL_MS = 500
 
 /** Remote builds need room for the Next.js compiler beside what the server already runs. */
 const REMOTE_BUILD_MIN_MIB = 1800
@@ -177,9 +178,12 @@ export function healthCommand(healthPath: string | null): string {
  * `docker run` for one deployment. No port is published: Caddy reaches the
  * container on the nextship network, so nothing on the server's public address
  * bypasses the proxy. The interval, timeout and retries match the image's own
- * HEALTHCHECK; the start interval makes Docker check every two seconds while the
+ * HEALTHCHECK; the start interval makes Docker check every half second while the
  * container starts, where Docker supports it, so a healthy deployment switches in
- * seconds rather than after the first 30 second interval.
+ * about a second rather than after the first 30 second interval. Measured on a warm
+ * deployment: at two seconds, with the deploy polling every two seconds too, a
+ * container that was ready at once was seen as healthy four seconds after it started,
+ * a quarter of the whole deployment.
  *
  * The container is hardened so that code an attacker runs inside it cannot stay.
  * The self-hosted break-ins through CVE-2025-55182 in December 2025 edited
@@ -231,7 +235,7 @@ export function runArguments(options: {
     '--health-start-period', '10s',
     '--health-retries', '3',
     // API 1.44, Docker 25. Setup accepts Docker 23, which would refuse the flag.
-    ...(options.dockerMajor >= 25 ? ['--health-start-interval', '2s'] : []),
+    ...(options.dockerMajor >= 25 ? ['--health-start-interval', '500ms'] : []),
     `${name}:${imageTag}`,
   ]
 }

From 310dc1c7531c937ceee646f53b046e75f92e99c0 Mon Sep 17 00:00:00 2001
From: ragul2105 
Date: Tue, 29 Sep 2026 12:45:49 +0530
Subject: [PATCH 06/11] feat(doctor): print what vercel.json did, ready to use
 elsewhere

A cron job in vercel.json never runs off Vercel, and nothing reports it.
doctor now prints a crontab line for each, calling its route with
CRON_SECRET as a bearer token, as Vercel does, and says Vercel runs
schedules in UTC. Redirects, rewrites and headers that use only fields
next.config accepts are printed as a next.config snippet; any other, or a
redirect that does not say whether it is permanent, is named rather than
guessed.

This is the part of a Vercel migration plan that needs no Vercel account.
Domains and env var names need Vercel's API and stay in the roadmap.

Co-Authored-By: Claude Opus 5.5 
---
 CHANGELOG.md                    |  6 ++++
 README.md                       |  4 +--
 docs/roadmap.md                 |  2 +-
 packages/cli/src/doctor.test.ts | 44 ++++++++++++++++++++++--
 packages/cli/src/doctor.ts      | 60 ++++++++++++++++++++++++++++++---
 packages/cli/src/index.ts       |  1 +
 site/content/docs/commands.mdx  |  4 +--
 7 files changed, 110 insertions(+), 11 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c8ee757..45c3008 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,12 @@ All notable changes to this repository. Attribution rules: `AGENTS.md` §1.1.
   Caddy back to its running container, 208 ms in the end-to-end test, keeping its own env,
   and a timer stops the kept container when its hour is over. `env push` and domain changes
   keep nothing extra. (Ragul D)
+- **`doctor` hands over what `vercel.json` did.** A cron job becomes a crontab line that
+  calls its route with `CRON_SECRET` as a bearer token, as Vercel does. Redirects, rewrites
+  and headers that use only fields `next.config` accepts are printed as a `next.config`
+  snippet; any other, or a redirect that does not say whether it is permanent, is named
+  rather than guessed. The part of a migration that needs Vercel's API, domains and env var
+  names, is not built. (Ragul D)
 - **Previews.** `nextship deploy --preview ` deploys the current source as a second
   app on the same server, with its own env file and domains, never taking the server's
   address and asking crawlers not to index it. The same flag scopes `rollback`, `logs`,
diff --git a/README.md b/README.md
index de6a396..3f07531 100644
--- a/README.md
+++ b/README.md
@@ -287,8 +287,8 @@ is reported as a blocker.
 | Checked | Why it matters |
 |---|---|
 | `@vercel/analytics` and similar packages | They stop reporting and nothing errors |
-| Cron jobs in `vercel.json` | They will simply never run |
-| Routing rules in `vercel.json` | They stop applying |
+| Cron jobs in `vercel.json` | They will simply never run. `doctor` prints a crontab line for each that calls its route as Vercel does, with `CRON_SECRET` as a bearer token |
+| Routing rules in `vercel.json` | They stop applying. `doctor` prints the redirects, rewrites and headers that carry over unchanged as a `next.config` snippet, and names each one that needs a decision, such as a redirect that does not say whether it is permanent |
 | `VERCEL_URL` and `VERCEL_ENV` read in source | They become undefined |
 | Packages in `node_modules` that nothing declares | They exist on your machine and not in the image, so the build fails on an import that resolves locally |
 | A missing lockfile | Installs are no longer reproducible |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 8e281b7..3dc1071 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -284,7 +284,7 @@ first are the ones a new user meets in their first ten minutes:
 | 6 | **A preview deployment per pull request on the same server**, removed when the pull request closes | **Done**, `--preview `, verified end to end. The pull request workflow in `vm.md` §6 has not run in GitHub Actions |
 | 7 | **Deploy time measured stage by stage**, then shortened where nextship owns the time | **Done.** The health wait was 4.1 s of a 16.6 s warm deployment and is 1.1 s now; three deployments took 13.3 to 15.0 s, and 7.7 s or more of that is `next build`. See `design.md` §9.3, Release sequence. Under ten seconds is not reachable without a faster `next build`, so it is not promised |
 | 8 | **Build once, promote to another environment** with its own `NEXT_PUBLIC_*` values | **Concluded no.** See "Deliberately not built" |
-| 9 | **A migration plan from Vercel**: domains, cron jobs, routing rules and the names of env vars | Vercel's API never returns a sensitive env var's value, so the plan asks for those rather than importing blanks, for the reason `env pull` is not built |
+| 9 | **A migration plan from Vercel**: domains, cron jobs, routing rules and the names of env vars | **Local part done:** `doctor` prints a crontab line per cron job and a `next.config` snippet of the routes that carry over unchanged. **Not built:** domains and env var names, which need Vercel's API and a Vercel account to verify against. Vercel never returns a sensitive env var's value, so that part would ask for them, for the reason `env pull` is not built |
 | 10 | **Peak build memory in the build result**, and a warning near the limit | `server add` already adds swap below 4 GB, and remote builds are refused below 2 GB |
 
 Correctness across several servers stays under v2 below, with its trigger.
diff --git a/packages/cli/src/doctor.test.ts b/packages/cli/src/doctor.test.ts
index 5ab8c15..d951642 100644
--- a/packages/cli/src/doctor.test.ts
+++ b/packages/cli/src/doctor.test.ts
@@ -12,7 +12,7 @@ import assert from 'node:assert/strict'
 import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import path from 'node:path'
-import { diagnose, type Finding } from './doctor.js'
+import { cronLines, diagnose, translateRoutes, type Finding } from './doctor.js'
 import type { ProjectInfo } from './detect.js'
 
 const projectFor = (root: string, overrides: Partial = {}): ProjectInfo => ({
@@ -236,7 +236,7 @@ test('on a vm target, revalidation is not warned about and cron advice names the
   try {
     const findings = await diagnose(projectFor(root), 'vm')
     assert.equal(titled(findings, 'On-demand revalidation'), undefined)
-    assert.match(titled(findings, 'cron job')?.action ?? '', /systemd timer/)
+    assert.match(titled(findings, 'cron job')?.action ?? '', /on your server, for example with these crontab lines/)
     assert.doesNotMatch(titled(findings, 'cron job')?.action ?? '', /DigitalOcean/)
     assert.equal(titled(findings, 'does not survive a restart'), undefined)
     assert.ok(titled(findings, 'survive a restart'))
@@ -263,3 +263,43 @@ test('a vm project is warned that it runs on one server, and about request.url w
     await rm(root, { recursive: true, force: true })
   }
 })
+
+// A cron job left behind on Vercel never runs and nothing reports it, so doctor
+// hands over the line that runs it, calling the route the way Vercel does.
+test('each Vercel cron job becomes a crontab line that calls its route with the secret Vercel sends', () => {
+  assert.deepEqual(cronLines([{ path: '/api/digest', schedule: '0 5 * * *' }]), [
+    '0 5 * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" "$APP_URL/api/digest"',
+  ])
+  assert.match(cronLines([{ path: 'api/x', schedule: '* * * * *' }])[0], /^# not translated/)
+  assert.match(cronLines([{ path: '/api/x' }])[0], /^# not translated/)
+})
+
+// next.config takes the same shape as vercel.json for these, so an entry that uses
+// nothing else is handed over as it is; anything else is named, never guessed.
+test('routes carry over into next.config only when nothing about them has to be guessed', () => {
+  const redirects = translateRoutes('redirects', [
+    { source: '/old', destination: '/new', permanent: true },
+    { source: '/moved', destination: '/there' },
+    { source: '/legacy', destination: '/x', permanent: false, regex: true },
+  ])
+  assert.deepEqual(redirects.translated, [{ source: '/old', destination: '/new', permanent: true }])
+  assert.deepEqual(redirects.left, ['/moved does not say whether it is permanent', '/legacy uses regex'])
+
+  const headers = translateRoutes('headers', [{ source: '/(.*)', headers: [{ key: 'X-Frame-Options', value: 'DENY' }] }])
+  assert.equal(headers.translated.length, 1)
+  assert.deepEqual(translateRoutes('rewrites', [{ destination: '/x' }]).left, ['{"destination":"/x"} has no source'])
+})
+
+test('doctor prints the translated routes as a next.config snippet, and names what it left', async () => {
+  const root = await fixture({
+    'package.json': JSON.stringify({ name: 'demo', dependencies: { next: '16.3.4' } }),
+    'vercel.json': JSON.stringify({ redirects: [{ source: '/a', destination: '/b', permanent: true }, { source: '/c', destination: '/d' }] }),
+  })
+  try {
+    const finding = titled(await diagnose(projectFor(root), 'vm'), 'defines redirects')
+    assert.deepEqual(finding?.lines, ['async redirects() {', '  return [{"source":"/a","destination":"/b","permanent":true}]', '},'])
+    assert.match(finding?.action ?? '', /1 of 2 carries over as it is.*\/c does not say whether it is permanent/)
+  } finally {
+    await rm(root, { recursive: true, force: true })
+  }
+})
diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts
index 016f1e3..1c2f110 100644
--- a/packages/cli/src/doctor.ts
+++ b/packages/cli/src/doctor.ts
@@ -29,6 +29,8 @@ export interface Finding {
   consequence: string
   /** What to do about it. */
   action: string
+  /** Text to use as it stands, such as crontab lines or a next.config snippet, when the action needs it. */
+  lines?: string[]
 }
 
 /** Vercel packages that degrade silently rather than failing when they are off Vercel. */
@@ -158,19 +160,27 @@ async function vercelConfigFindings(root: string, target: TargetId): Promise 0) {
+      const { translated, left } = translateRoutes(key, config[key])
       findings.push({
         level: 'warning',
         title: `vercel.json defines ${key}`,
         consequence: `These ${key} stop applying. Only the equivalents in next.config are used.`,
-        action: `Move them into next.config, which works on any host.`,
+        action:
+          translated.length > 0
+            ? `Move them into next.config, which works on any host. ${translated.length} of ${config[key].length} ${translated.length === 1 ? 'carries' : 'carry'} over as ${translated.length === 1 ? 'it is' : 'they are'}, in the lines below.` +
+              (left.length > 0 ? ` Translate the other ${left.length} by hand: ${left.join('; ')}.` : '')
+            : `Move them into next.config, which works on any host. None carries over as it is: ${left.join('; ')}.`,
+        ...(translated.length > 0 ? { lines: [`async ${key}() {`, `  return ${JSON.stringify(translated)}`, '},'] } : {}),
       })
     }
   }
@@ -178,6 +188,48 @@ async function vercelConfigFindings(root: string, target: TargetId): Promise {
+    const { path: route, schedule } = (cron ?? {}) as { path?: unknown; schedule?: unknown }
+    if (typeof route !== 'string' || !route.startsWith('/') || typeof schedule !== 'string') {
+      return `# not translated, it has no path starting with / or no schedule: ${JSON.stringify(cron)}`
+    }
+    return `${schedule} curl -fsS -H "Authorization: Bearer $CRON_SECRET" "$APP_URL${route}"`
+  })
+}
+
+/**
+ * The fields next.config accepts for each kind of route. vercel.json uses the same
+ * shape for these, so an entry that uses nothing else carries over unchanged. An
+ * entry with any other field, or a redirect that does not say whether it is
+ * permanent, which next.config requires, is left for a person to decide.
+ */
+const NEXT_ROUTE_FIELDS = {
+  redirects: ['source', 'destination', 'permanent', 'statusCode', 'basePath', 'locale', 'has', 'missing'],
+  rewrites: ['source', 'destination', 'basePath', 'locale', 'has', 'missing'],
+  headers: ['source', 'headers', 'basePath', 'locale', 'has', 'missing'],
+} as const
+
+export function translateRoutes(
+  key: keyof typeof NEXT_ROUTE_FIELDS,
+  entries: unknown[]
+): { translated: Record[]; left: string[] } {
+  const translated: Record[] = []
+  const left: string[] = []
+  for (const entry of entries) {
+    const route = (entry ?? {}) as Record
+    const source = typeof route.source === 'string' ? route.source : JSON.stringify(entry)
+    const unknown = Object.keys(route).filter((field) => !(NEXT_ROUTE_FIELDS[key] as readonly string[]).includes(field))
+    if (typeof route.source !== 'string') left.push(`${source} has no source`)
+    else if (unknown.length > 0) left.push(`${source} uses ${unknown.join(', ')}`)
+    else if (key === 'redirects' && route.permanent === undefined && route.statusCode === undefined) {
+      left.push(`${source} does not say whether it is permanent`)
+    } else translated.push(route)
+  }
+  return { translated, left }
+}
+
 /**
  * The calls that revalidate on demand. App Platform's CDN keeps fully static pages
  * for as long as Next.js allows, which is a year, so these only reach visitors of a
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index ecf09dc..127097d 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -301,6 +301,7 @@ function report(finding: Finding): void {
   if (finding.level === 'note') detail(line)
   else warn(line)
   detail(`  ${finding.action}`)
+  for (const line of finding.lines ?? []) detail(`    ${line}`)
 }
 
 /** Reads `--flag value` pairs, so an unknown flag is an error rather than silently ignored. */
diff --git a/site/content/docs/commands.mdx b/site/content/docs/commands.mdx
index ae2ea2d..b6010de 100644
--- a/site/content/docs/commands.mdx
+++ b/site/content/docs/commands.mdx
@@ -91,8 +91,8 @@ in `node_modules` that nothing declares: that one fails the build, and is a bloc
 | Checked | Why it matters |
 |---|---|
 | Analytics and similar packages | They stop reporting and nothing errors |
-| Cron jobs in platform config | They will simply never run |
-| Routing rules in platform config | They stop applying |
+| Cron jobs in platform config | They will simply never run. `doctor` prints a crontab line for each that calls its route |
+| Routing rules in platform config | They stop applying. `doctor` prints those that carry over unchanged as a `next.config` snippet, and names the rest |
 | Platform URL and environment variables read in source | They become undefined |
 | Packages in `node_modules` that nothing declares | They exist on your machine and not in the image, so the build fails on an import that resolves locally |
 | A missing lockfile | Installs are no longer reproducible |

From e7635ccbdede1c79b925c381eca2ddaec85d36c4 Mon Sep 17 00:00:00 2001
From: ragul2105 
Date: Wed, 30 Sep 2026 13:32:53 +0530
Subject: [PATCH 07/11] test: make the prune tests pass on macOS, and run CI
 there

Five prune tests created their fixtures under the temporary directory as
named. On macOS that is /var, a link to /private/var, and prune compares
the paths it resolves through links with the root it was given, so every
traced file looked outside the build root. The fixtures are now created
at the real path. prune itself was never affected in the image, where the
root is /src. CI now runs the CLI tests on macOS too, since it ran only
Linux and Windows and never saw this.

Co-Authored-By: Claude Opus 5.5 
---
 .github/workflows/ci.yml       | 4 +++-
 CHANGELOG.md                   | 8 ++++++++
 packages/cli/src/prune.test.ts | 8 ++++++--
 3 files changed, 17 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 86d644b..b9a2be7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,7 +32,9 @@ jobs:
       # One platform failing says nothing about the others, so all of them report.
       fail-fast: false
       matrix:
-        os: [ubuntu-latest, windows-latest]
+        # macOS because people deploy from Macs, and because its temporary directory
+        # is reached through a symlink, which once failed five tests nobody else saw.
+        os: [ubuntu-latest, windows-latest, macos-latest]
         # 22 is the engines floor; 24 is the current LTS line.
         node: ['22', '24']
     defaults:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45c3008..e73a6a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,14 @@ All notable changes to this repository. Attribution rules: `AGENTS.md` §1.1.
   writes files outside `.next` now fails with `EROFS`, which the README and `docs/vm.md`
   §4 state. (Ragul D)
 
+### Fixed
+
+- **The unit tests pass on macOS.** Five prune tests created their fixtures under the
+  temporary directory as named, which on macOS is a link to `/private/var`, so every path
+  prune resolved looked outside the build root. CI ran only Linux and Windows and never saw
+  it; it runs macOS too now. The image was never affected: prune runs under `/src` there.
+  (Ragul D)
+
 ### Docs
 
 - **The evidence page compares nextship with Coolify, measured.** The same app deployed
diff --git a/packages/cli/src/prune.test.ts b/packages/cli/src/prune.test.ts
index 24aef0b..276a0f8 100644
--- a/packages/cli/src/prune.test.ts
+++ b/packages/cli/src/prune.test.ts
@@ -8,7 +8,7 @@
 import { test } from 'node:test'
 import assert from 'node:assert/strict'
 import { spawnSync } from 'node:child_process'
-import { access, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
+import { access, lstat, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import path from 'node:path'
 import { fileURLToPath } from 'node:url'
@@ -16,7 +16,11 @@ import { fileURLToPath } from 'node:url'
 const PRUNE = fileURLToPath(new URL('../runtime/prune.cjs', import.meta.url))
 
 async function fixture(layout: Record): Promise {
-  const root = await mkdtemp(path.join(tmpdir(), 'nextship-prune-'))
+  // The real path, because prune resolves the paths it follows through links and
+  // compares them with the root it was given. On macOS the temporary directory is
+  // under /var, a link to /private/var, so a root named through the link made every
+  // resolved path look outside it. In the image the root is /src, which is not a link.
+  const root = await realpath(await mkdtemp(path.join(tmpdir(), 'nextship-prune-')))
   for (const [relative, content] of Object.entries(layout)) {
     const file = path.join(root, relative)
     await mkdir(path.dirname(file), { recursive: true })

From bd02f62a87e1108b24324987258ee7a82133da6b Mon Sep 17 00:00:00 2001
From: ragul2105 
Date: Wed, 30 Sep 2026 13:41:56 +0530
Subject: [PATCH 08/11] feat(vm): answer https://
before there is a domain On a server added by a public IPv4 address, the default app's Caddy site also answers https://
, asking Let's Encrypt for a certificate under its shortlived profile, the only one it issues IP certificates under since they became generally available on 2026-01-15. deploy reports the https address only once a TLS connection to it verifies, and the certificate check no longer sends an IP address as the server name. Hostnames, IPv6 (fixed in Caddy after the 2.10.2 setup installs) and private or documentation ranges get no certificate of their own. Verified against Pebble, Let's Encrypt's test server, on a private network: Caddy 2.10.2 running the site renderSite produced obtained a certificate through HTTP-01 on port 80 despite the catch-all HTTP site, naming IP Address:10.77.0.10 and valid for six days, and a client trusting only Pebble's root fetched the app over HTTPS. Not yet run against Let's Encrypt on a real server; the docs say so. e2e.sh still passes 25 of 25. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++ README.md | 9 ++++ docs/design.md | 19 +++++++- docs/roadmap.md | 2 +- docs/vm.md | 2 +- packages/cli/src/targets/vm/caddy.test.ts | 12 ++++- packages/cli/src/targets/vm/caddy.ts | 25 +++++++++++ packages/cli/src/targets/vm/vm-target.test.ts | 14 ++++++ packages/cli/src/targets/vm/vm-target.ts | 44 ++++++++++++++++--- site/content/docs/vm.mdx | 5 ++- 10 files changed, 126 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e73a6a1..8ce406b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ All notable changes to this repository. Attribution rules: `AGENTS.md` §1.1. snippet; any other, or a redirect that does not say whether it is permanent, is named rather than guessed. The part of a migration that needs Vercel's API, domains and env var names, is not built. (Ragul D) +- **HTTPS before there is a domain.** On a server added by a public IPv4 address, the first + app also answers `https://
`, with a six-day certificate Let's Encrypt issues for + the address itself under its `shortlived` profile, renewed by Caddy. `deploy` reports the + `https://` address only once it verifies. Verified end to end against Pebble, Let's + Encrypt's test server; not yet against Let's Encrypt on a real server. (Ragul D) - **Previews.** `nextship deploy --preview ` deploys the current source as a second app on the same server, with its own env file and domains, never taking the server's address and asking crawlers not to index it. The same flag scopes `rollback`, `logs`, diff --git a/README.md b/README.md index 3f07531..b31a27e 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,15 @@ one serving, and a deployment that succeeds drops no request. Its last log lines printed when it fails. The first app deployed on a server answers `http://`; later apps answer nothing until `nextship domain add`. +**HTTPS before you have a domain.** When you added the server by a public IPv4 address, the +first app also answers `https://
`, with a certificate Let's Encrypt issues for the +address itself. Let's Encrypt certifies an address only for about six days at a time, and +Caddy renews it on its own. `deploy` prints the `https://` address once it really answers +with a valid certificate, and the `http://` one until then. A server added by hostname, by an +IPv6 address, or by a private address gets no certificate of its own: attach a domain +instead. This has been verified end to end against Pebble, Let's Encrypt's own test +server, and not yet against Let's Encrypt on a real server. + The Server Actions key is kept on the server rather than in `.nextship/secrets.local.json`, so every machine that deploys, including CI, builds with the same key. A key already in your local file is copied to the server on the first deploy. If the server and your local diff --git a/docs/design.md b/docs/design.md index 857f5ed..46df0ca 100644 --- a/docs/design.md +++ b/docs/design.md @@ -592,7 +592,7 @@ Everything nextship keeps on the server lives in a few places: | `/etc/nextship/apps//deployments.json` | Deployment history, newest first: `{ id, imageTag, createdAt, cause, served, live }` | | `/etc/nextship/apps//env`, `secrets` | Runtime variables and the Server Actions key, mode 0600, written from SSH stdin | | `/etc/nextship/apps//lock` | A directory created with `mkdir`, so two deployments cannot interleave, holding an owner file | -| `/etc/nextship/default-app` | The app that answers `http://`: the first one to deploy | +| `/etc/nextship/default-app` | The app that answers `http://`: the first one to deploy. On a server added by a public IPv4 address it also answers `https://
` (Release sequence, below) | | Volumes `nextship--build-`, `nextship--cache` | What Next.js writes at runtime, kept across restarts (below) | | `/etc/nextship/caddy/sites/.caddy` | The Caddy site for the app | | Docker network `nextship` | Caddy and every app container; no app publishes a port | @@ -661,6 +661,23 @@ being replaced. `deployments.json` records the kept one with `keptUntil`, and be `server add` installs, since the server has no JSON parser. `images prune` and the prune after a deployment keep the kept deployment's image, which Docker would refuse to remove. +**HTTPS on the server's address.** When the server was added by a literal public IPv4 +address (`publicIpv4`: not private, shared, loopback, link-local, documentation or +multicast), the default app's site also has a block for `https://
` whose ACME +issuer asks for Let's Encrypt's `shortlived` profile, the only one under which Let's Encrypt +issues IP address certificates since they became generally available on 2026-01-15. Caddy +answers the HTTP-01 challenge before any site route, so the catch-all HTTP site does not +intercept it. IPv4 only: IPv6 issuance was fixed in Caddy after 2.10.2, the release +`caddy:2.10` installs (caddyserver/caddy#7399). `address` reports `https://` only once a TLS +connection to the address verifies. The certificate check sends no server name for an +address, which TLS forbids. Verified against Pebble 2026, Let's Encrypt's test server, with +a `shortlived` profile of six days: on a private network, Caddy 2.10.2 running the site file +`renderSite` produced for `10.77.0.10` obtained a certificate through HTTP-01 on port 80, +naming `IP Address:10.77.0.10` and valid from 2026-09-30 08:05 to 2026-10-06 08:05, and a +client trusting only Pebble's root fetched the app over `https://10.77.0.10` while one +trusting nothing was refused. Not yet run against Let's Encrypt itself, which needs a server +on a public address. + **Previews.** `--preview ` (`preview.ts`) scopes a command to the app `-` on the project's server. Its `app.json` records `previewOf`, the id of the app it previews, and `ownedApp` accepts a preview only when that id is the one in diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dc1071..4625fd1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -277,7 +277,7 @@ first are the ones a new user meets in their first ten minutes: | Order | Item | State and what decides it | |---|---|---| | 1 | **Hardened containers.** Read-only root filesystem, a `noexec` `/tmp`, no capabilities, `no-new-privileges`, a process limit | **Done.** See `design.md` §9.3, Security | -| 2 | **HTTPS on a bare IP.** A first deployment answers `https://` with a valid certificate, before any domain exists | Let's Encrypt made IP address certificates generally available on 2026-01-15, only as six day certificates. Caddy needs its `shortlived` profile for them, and Caddy issue #7399 reports IP issuance failing with it. Verify on a Droplet before building | +| 2 | **HTTPS on a bare IP.** A first deployment answers `https://` with a valid certificate, before any domain exists | **Built, verified against Pebble.** See `design.md` §9.3, Release sequence. Still to run: one deployment to a server on a public IPv4 address, against Let's Encrypt itself | | 3 | **A cap on the cache volumes, and disk watched while apps run.** Least recently used eviction when an app's runtime writes pass their limit | **Done.** See `design.md` §9.3, Cache guard | | 4 | **Skew protection and instant rollback.** The previous deployment's container keeps running for an hour, Caddy sends each request to the build the page was loaded from, and a rollback within that hour only reloads Caddy | **Done.** See `design.md` §9.3, Release sequence | | 5 | **The compatibility suite nightly against the newest canary, published on the site** | **Built, not yet run.** `conformance.yml` runs nightly and publishes to the `evidence` branch, which the evidence page reads. Verified once the first scheduled run publishes, which needs this merged to `main` | diff --git a/docs/vm.md b/docs/vm.md index c4ea9f6..ea7da86 100644 --- a/docs/vm.md +++ b/docs/vm.md @@ -105,7 +105,7 @@ keeps on the server: | `/etc/nextship/apps//env` | Runtime variables, mode 0600, in Docker's env file format | | `/etc/nextship/apps//secrets` | The Server Actions key, mode 0600 | | `/etc/nextship/caddy/sites/.caddy` | The app's Caddy site, regenerated on every change | -| `/etc/nextship/default-app` | The app that answers `http://` | +| `/etc/nextship/default-app` | The app that answers `http://`, and `https://
` when the server was added by a public IPv4 address | | Containers `-r