Repository navigation
Conversation
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…k, 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 <name> scopes deploy, rollback, logs, env, domain, images and destroy to a second app on the same server, <app>-<name>. 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
On a server added by a public IPv4 address, the default app's Caddy site also answers https://<address>, 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 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
nextship | 543f4cf | Commit Preview URL Branch Preview URL |
Oct 10 2026, 06:51 PM |
gowtham472
left a comment
There was a problem hiding this comment.
Thanks for this, Ragul. The design is clear, the tests are real (25 of 25 in e2e.sh, CI green on Linux, Windows and macOS), and the "not yet verified" list is honest. Not ready to merge yet: keeping the previous container running adds a timer, a second container and a second file, and most of what follows is where those meet.
This is from reading the whole diff, not from running it on a server, so each item needs confirming, the Caddy one above all.
Must fix before merging
-
A server set up by 1.1.x keeps the previous container for good.
vm-target.ts(thekeptfilter afterrecordLive). Deploy now leaves the replaced container running and relies on the retire timer, but a setup version 2 server has no timer, and deploy neither refuses nor warns: onlyserver statusmentions the old setup. The container runs with its full memory limit until the next deployment, and the@previousroute stays. Deploy should refuse, or keep nothing, untilserver addhas run again. -
The retire timer can stop the live container.
retire.sh, thedocker stop. It stops whateverpreviousnames once the time has passed, and never checks that the container is not the one Caddy serves.writeDeploymentswritesdeployments.jsonandpreviousas two separate commands, androllbackInstantlyswitches Caddy before either. A rollback near the hour, or a CLI that dies between the switch and the second write, leavespreviousnaming the container that is now live, and the timer stops it.--restart unless-stoppeddoes not bring back a stopped container. -
rollbackInstantlychecks before it takes the lock. It reads the history and inspects the container, then locks, and does not check again. A deploy that goes live in between stops that container (it is no longer the kept one), and the rollback then points Caddy at it. -
https://<address>may make plain HTTP redirect before a certificate exists.caddy.ts, thehttps://${options.ipCertificate}block. Caddy's automatic HTTPS adds an HTTP redirect for a host it manages a certificate for, and a host-specific redirect can win over the catch-allhttp://:80site. If Let's Encrypt is slow or cannot issue (443 closed upstream, a rate limit), the only address a first deploy printed would redirect to a URL with no valid certificate. Pebble always issues, so the test cannot show this. Please check the rendered site withcaddy adapt, and run once with issuance failing. -
Destroying an app orphans its previews.
destroyAppremoves the named app only, and the plan does not mention previews. Afterwardsnextship.jsonhas noappId, so--preview <name>can never matchpreviewOfagain, and the previews keep running with their volumes and domains.
Should fix, or decide
-
The read-only root has no way out, and deploy cannot see the failure. An app that writes a SQLite file or uploads under its working directory deploys, passes the health check on a prerendered route, and then fails with
EROFSon its first write. Either a declared writable path, or a check that warns at deploy time. -
The memory limit does not count the kept container or previews.
memoryLimitstill divides 80% of RAM by the number of apps. One app on a 2 GB server gets 1600m, and for an hour two containers are each allowed that. Previews are full apps, and containers already running keep the larger share. -
The docs describe HTTPS on the address as working. README and
site/content/docs/vm.mdxsay the first app answershttps://<address>, while this pull request lists that as not yet run against Let's Encrypt. AGENTS.md §5: "Never claim a capability in docs or output that has not been observed working on a real target." Either run it on a public server first, or move the claim to the roadmap until then.
Minor
-
Cache eviction order depends on access times.
cache-guard.shsorts by%A@. Underrelatimethat changes at most once a day, and undernoatimenever, so the guard would evict by age, the hottest entries included. -
A long app name with a preview name gives the wrong error.
previewAppNamecan pass 63 characters, and the refusal that follows tells the user to rename the app.
Happy to look again once 1 to 5 are in. After the merge, the plan is a live run on a Droplet (the upgrade from a 1.1.2 server included), then 1.2.0, since the read-only filesystem changes behaviour.
Conflicts in CHANGELOG.md, docs/vm.md, deploy.ts and vm-target.ts. deploy.ts takes main's untilStopped structure, with the preview's previewOf on the release and no nextship.json write for a preview. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review point 9. The cache guard sorted by access time alone. Under relatime that changes at most once a day, and under noatime never, where every file ties and the busiest entries went with the idle. It now sorts by the later of a file's last access and last write, so a volume that keeps no access times is evicted oldest written first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imit Review points 5 and 10. Destroying an app removed its id from nextship.json, which is what finds its previews, so they kept their containers, volumes and domains with nothing able to reach them. The plan now names each preview and they are destroyed first, images included. A preview name too long for the app went on to the app name check, whose refusal said to rename the app. It is refused before that, with the length the preview name may be. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… writable folders Review points 1 to 4 and 6 to 8 of pull request 7. 1. A server set up by 1.1.x has no retire timer. A deployment reads the server's setup version and on an older one keeps nothing, stops the replaced container as before, and says to run server add again. 2. The retire timer could stop the live container. previous is emptied before Caddy is pointed anywhere and written with the history in one command, and retire.sh stops nothing the Caddy site names on its new "# live" line. 3. An instant rollback read the history and the container before taking the lock. It reads both once the lock is held. 4. With only https://<address> named, Caddy redirected plain HTTP on the address to HTTPS before any certificate existed: measured, a 308 with a CA that could not be reached. The site names both schemes, and with issuance failing HTTP answers 200; with it then succeeding, both do. 6. "writable" in nextship.json lists folders the app may write to, each a volume per app that outlives a deployment. doctor warns about source that looks like it writes files with none declared; destroy and server move say what happens to that data. 7. The default memory limit counts two containers per app where the replaced deployment is kept, so both together stay within 80% of RAM. 8. The README and the site no longer describe HTTPS on the address, which has not run against Let's Encrypt; the roadmap holds it. Also found while fixing 2: nothing kept was written as a lone newline, which the timer would have logged as unreadable every minute. e2e.sh passes 28 of 28 on a local Ubuntu 24.04 stand-in, with new checks for a writable folder across a deployment, the timer refusing the live container, a server with the older setup, and an app's preview going with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review, Gowtham. All ten points are addressed, and Must fix
Should fix
Minor
Found along the way
Still open, for the Droplet run
Commits: |
The server target's next phase, in the order
docs/roadmap.mdrecords, each shipped and verified on its own.What changes
noexec/tmp, no capabilities,no-new-privileges, 512 processes. The December 2025 break-ins through CVE-2025-55182 rewrotenext.config.jsand ran miners from/tmp; none of that can persist now. Behaviour change: an app that writes outside.nextfails withEROFS(documented).x-deployment-id,?dpl=). This sidesteps Server Action responses bypassdeploymentIdversion skew detection, crashing stale tabs with "module factory is not available" vercel/next.js#99165. A retire timer stops it afterwards.--preview <name>deploys and manages a second app on the same server, found again from the server record so CI needs only the committednextship.json, withnoindexand its own env. A workflow for one preview per pull request is indocs/vm.md§6.https://<address>with Let's Encrypt's six-day IP certificate.next build.doctorprints crontab lines forvercel.jsoncrons, and anext.configsnippet for the routes that carry over unchanged.evidencebranch that the evidence page reads.x-middleware-subrequest(no supported Next.js version is affected).How it was verified
conformance/vm/e2e.shpasses 25 of 25 on a local Ubuntu 24.04 stand-in, with new checks for hardening, the cache guard, skew routing, instant rollback, retire and previews.Not yet verified (needs GitHub or a real server)
main.docs/vm.md§6: never run in GitHub Actions.nextship server addagain for setup version 3 (cache guard and retire timers).server statussays so.🤖 Generated with Claude Code