Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,47 @@ All notable changes to this repository. Attribution rules: `AGENTS.md` §1.1.

## [Unreleased]

### Fixed

- **Closing the terminal part way through a deployment no longer leaves the app locked.**
`deploy` and `rollback` handled Ctrl+C by giving the release back: the new container
removed and the server's deploy lock released. A closed terminal (SIGHUP), `kill` and a
cancelled CI job (SIGTERM) ended the process where it stood instead, and the lock stayed
until someone removed it by hand. All of them are handled the way Ctrl+C is now, with
Ctrl+Break on Windows, and from before the release starts, so a stop that arrives while
the lock is being taken is not lost. Verified against a Droplet by closing the terminal of
a deployment that was waiting on its health check: 1.1.2 left the lock and the new
container behind, and this released the lock, removed the container and left the
previous deployment serving. Not yet run by closing a console window on Windows.
(Gowtham)
- **A remote build from Windows no longer opens the server's Docker on a local port.**
Windows' OpenSSH cannot forward to a Unix socket, so the build reached the server's
Docker through `127.0.0.1:<port>` with nothing authenticating it. For the length of a
build any process on the machine could use it, and so could a web page in a browser, by
rebinding a name to 127.0.0.1; Docker on the server is root on the server. The build now
goes through a named pipe with a random name, which a browser cannot open and only your
account, SYSTEM and Administrators can write to, and each connection is carried by its
own `ssh ... docker system dial-stdio` under the pinned host key. The warning the old
forward printed is gone with it. Verified from Windows 11 against a Droplet, the first
time `--build remote` has run from Windows: a first deployment in 127 s and a second in
73 s, the image on the server and not on the Windows machine, and streaming passing.
(Gowtham)
- **`server add` works from Windows.** It could not start: the `ssh-keyscan` Windows ships
cannot scan a current server, and ends with `choose_kex: unsupported KEX method
sntrup761x25519-sha512@openssh.com` against Ubuntu 24.04. Found by running it from
Windows against a Droplet. When the scan fails, the host key is now read by letting
`ssh` record it, in one connection that offers no way to authenticate, and it goes
through the same printed fingerprint and `--yes` as a scanned key. The fingerprint
printed matched the server's own. (Gowtham)

### Docs

- **The roadmap says where the CLI goes next.** A new section lists what is planned and
not built: machine-readable output, and more than one instance of an app, named as the
largest piece of work ahead with its design under "v2: correctness at scale". The fourth
sequencing principle now names the user the server target brought: anyone deploying
Next.js to a server they own. (Gowtham)

- **The evidence page compares nextship with Coolify, measured.** The same app deployed
with Coolify 4.3.23 and nextship 1.1.2 on identical Droplets: setup, cold and warm
deploy times, image size (987 MB against 407 MB), the memory each tool uses beside the
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ deployed, and a page that did not compile failed once, with no retry.

| Option | Default | Meaning |
|---|---|---|
| `--build <mode>` | `remote` | `remote` builds on the server's own Docker, reached through an SSH forward of its socket, so nothing but the build context leaves your machine and the image is built natively for the server. `local` builds here for the server's architecture and streams the image with `docker save \| ssh docker load`. Recorded in `nextship.json`. A server under 2 GB of RAM is refused for remote builds |
| `--build <mode>` | `remote` | `remote` builds on the server's own Docker, reached over SSH through a private socket, or a named pipe on Windows, so nothing but the build context leaves your machine, no port is opened, and the image is built natively for the server. `local` builds here for the server's architecture and streams the image with `docker save \| ssh docker load`. Recorded in `nextship.json`. A server under 2 GB of RAM is refused for remote builds |
| `--memory <size>` | an even share of 80% of RAM across the server's apps | The container's memory limit, such as `512m` |

```
Expand Down Expand Up @@ -1205,7 +1205,9 @@ Bun's adapter keeps a list of its own the same way.
| **v1.1** | Any Linux server over SSH | Released in 1.1.0. Verified end to end on a local test server, a Proxmox VM and DigitalOcean Droplets |
| **Next** | The rest of the server target's live checklist | Planned, not tied to a version: a Hetzner VM, an arm64 machine, Debian 12, a certificate for a real domain, and the GitHub Actions workflow |

Beyond v1.0, each with the trigger that would justify it: correctness at scale (a
Planned for the CLI, none of it built: two defects to fix, machine-readable output, and
more than one instance of an app, which is the largest piece of work ahead. Beyond v1.0,
each with the trigger that would justify it: correctness at scale (a
shared cache and distributed tags, needed once there is more than one instance),
git-driven previews, a hosted control plane, edge performance (which is where serving
static assets from a CDN now lives), and other frameworks. The roadmap also records
Expand Down
69 changes: 68 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,12 +602,79 @@ The server runs a `nextship` user in the `docker` group. **Membership of the `do
group is root-equivalent**, and the docs say so rather than implying the user is
unprivileged.

#### Stopping a release from outside (Implemented)

A release holds the app's deploy lock from the moment its container starts until traffic
has switched or the release has been given up, and nothing breaks that lock automatically.
A release that is stopped is therefore told, through one abort signal `deploy` and
`rollback` both watch, so it removes its new container and lets the lock go. Ctrl+C was
handled that way from the start. A closed terminal, which arrives as SIGHUP, and `kill` or
a cancelled CI job, which arrive as SIGTERM, were not: each ended the process where it
stood and left the app locked until someone removed the lock by hand. All of them abort
the same signal now, and on Windows so does Ctrl+Break. The listeners are attached before
the release starts, so a stop that arrives while the lock is being taken is acted on as
soon as the release can be given back. After a stop, a failed write to a terminal that is
gone is ignored rather than allowed to end the process before the lock is released.

Verified on Linux with real signals: a process holding a stand-in lock was sent SIGHUP,
SIGTERM and SIGINT in turn, and each time released it and exited by itself.

Then against a real server. Gowtham ran these on 2026-10-09, on a DigitalOcean Droplet (`s-2vcpu-4gb`, Ubuntu 24.04, `blr1`), from Ubuntu 24.04 under WSL. An app
made to take three minutes to start was deployed inside a terminal of its own, and that
terminal was closed once the lock was held and the deployment was waiting on the health
check. With 1.1.2 from npm the process died, and 25 s later the lock was still on the
server, naming a process that no longer existed, with the new container still running
beside the old one. With this fix, 25 s later the lock was gone, the new container had
been removed, the previous one was still healthy and answering 200, and no nextship
process was left. A normal deployment afterwards went live with nothing removed by hand.

**Not yet run:** closing a console window on Windows part way through a deployment.
Windows delivers that as SIGHUP and ends the process a few seconds later whatever it is
doing, and giving a release back takes two or three SSH logins, each a new connection
there. One attempt closed its window during the build, before any lock was taken, and
showed nothing either way.

#### Build and delivery (Implemented)

The default build mode is **remote**: the image is built by the server's own Docker daemon,
reached through `ssh -L` forwarding a local socket to `/var/run/docker.sock`. That keeps the
pinned host key in force, which `DOCKER_HOST=ssh://` would bypass, builds for the server's
own architecture natively, and sends only the build context rather than a finished image.
The socket is in a directory only the user running nextship can enter.

**From Windows the daemon is reached through a named pipe, not a port.** Windows' OpenSSH
cannot forward to a Unix socket, and the first answer was a loopback TCP port, which
nothing authenticates: for as long as a build ran, any process on the machine could drive
the server's Docker, which is root on the server, and so could a web page open in a
browser, by rebinding a name to 127.0.0.1. Now nextship listens on a named pipe with a
random name and gives each connection the Docker client opens its own
`ssh ... docker system dial-stdio`, the command Docker itself uses to carry a connection
over SSH, started with nextship's own options so the pinned key still applies. A browser
cannot open a named pipe, and no port is opened on either machine. A pipe Node creates
takes Windows' default permissions for one, read from such a pipe on Windows 11: full
control for the user who made it, SYSTEM and Administrators, and read only for everyone
else, who therefore cannot send the daemon a request. Verified on Windows 11 with Docker
29.7.2: through the bridge module the Docker client reported the daemon's version and
built an image with a BuildKit secret, over 14 connections with none lost, the dialled
command running against the local daemon.

Then against a real server, from Windows 11 with its own OpenSSH (9.5p2) and no WSL
involved. Gowtham ran these on 2026-10-09, on a DigitalOcean Droplet (`s-2vcpu-4gb`, Ubuntu 24.04, `blr1`). `server add --yes` took 61 s. The first `deploy --yes` took
127 s and a second, after a one-line change, 73 s. The image existed on the server
(411 MB) and not on the Windows machine, the output carried no warning about a port,
streaming passed three runs of three with a first byte at 113 to 209 ms, and the Edge
route answered. This is the first time `--build remote` has run from Windows.

That run also found that `server add` could not start from Windows at all. The
`ssh-keyscan` Windows ships cannot scan a current server: it offers a key exchange it was
not built with, and ends with `choose_kex: unsupported KEX method
sntrup761x25519-sha512@openssh.com` against Ubuntu 24.04's OpenSSH 9.6. `ssh` on the same
machine negotiates correctly, so when the scan fails the key is now read by letting `ssh`
record it: one connection that offers no way to authenticate, sends nothing, and ends at
"permission denied" after the key exchange has written the server's key to a file that is
thrown away. The key then goes through the same printed fingerprint and `--yes` as a
scanned one, and the fingerprint it printed matched the server's own
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`.
A server under 2 GB of RAM is refused for remote builds with `--build local` as the action.
A remote build whose connection to the daemon drops is built once more over a new
forward, and only then. After a failed build the builder has two kinds of evidence: its
Expand Down Expand Up @@ -868,7 +935,7 @@ Not verified yet, and the live checklist in `docs/roadmap.md`:
| A hosting provider: a Hetzner VM, and arm64 on any real machine | An amd64 Proxmox VM and an amd64 DigitalOcean Droplet have passed (above). arm64 has run only against the container stand-in, and Hetzner has not been used |
| A certificate issued for a real domain, and streaming over HTTPS | Needs public DNS pointing at a reachable server; neither the Proxmox VM nor the Droplet used so far has had a domain pointed at it |
| The GitHub Actions workflow in `vm.md` | Not yet run; the `vm` CI job itself now passes on every push to `main` (`ssh localhost` stands in for a server) |
| `--build remote` from Windows over a loopback forward | No Windows machine was used |
| Closing a console window on Windows part way through a deployment | A closed terminal on Linux gives the lock back (Stopping a release, above); Windows has not been run |

#### Limitations

Expand Down
18 changes: 15 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ tried yet:
| `server add` on Debian 12 (item 2) | Debian 12 is a supported distribution that has only run on the stand-in |
| `domain add` with a real domain: the certificate is issued, and streaming works over HTTPS (item 4) | No server used so far had a domain pointed at it |
| A deployment from the GitHub Actions workflow in `vm.md` with `--build local` (item 11) | The documented CI path has never run |
| `--build remote` from Windows over the loopback forward | No Windows machine has built on a server |
| Closing a console window on Windows part way through a deployment | A closed terminal on Linux gives the lock back, on a real server. Windows ends the process a few seconds after the window closes, and that has not been run |

Known from the Droplet runs, to fix or document here: `conformance/vm/e2e.sh` needs a server
with no other app on it, and after adding swap `server add` prints `done swap already on`,
Expand Down Expand Up @@ -421,6 +421,17 @@ driver.

---

## Where the CLI goes next (Planned, not tied to a version)

The CLI's job is deploying Next.js to infrastructure you own, and what is planned is
doing that job more completely. None of this exists yet. Each row is built, verified on a
real server and documented on its own, and none has a date.

| Order | Item | State and what decides it |
|---|---|---|
| 1 | **Machine-readable output.** `--json` on the commands that report state or act, so CI and scripts read a result rather than scrape text | Planned |
| 2 | **More than one instance of an app.** A shared cache, tag invalidation that reaches every instance, and deployments that roll across them with no failed request | Planned, and the largest piece of work ahead. Its design is "v2: correctness at scale" below |

## Beyond v1.0

Not scheduled. Recorded so that the decision to skip them stays deliberate. Each
Expand Down Expand Up @@ -529,5 +540,6 @@ Recorded so the decision stays visible rather than looking like an oversight.
3. **Seams, not stubs.** Where a later version will swap an implementation, the
interface appears in the change that first consumes it, never before
([`../AGENTS.md`](../AGENTS.md) §3.1).
4. **Build for the current user.** Right now that user is one person with one app.
Features justified only by imagined future users wait until those users exist.
4. **Build for the current user.** Through 1.0 that user was one person with one app.
Since the server target it is anyone deploying Next.js to a server they own. Features
justified only by imagined future users still wait until those users exist.
15 changes: 11 additions & 4 deletions docs/vm.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ Author: Ragul D
your `ssh`.
- Nothing listening on ports 80 or 443. Caddy takes both.
- An OpenSSH client on the machine you run nextship from. macOS, Linux and Windows 10 and
later include one.
later include one. On Windows nothing else is needed: `server add` and a remote build
have run from Windows 11's own OpenSSH, with no WSL.

## 2. Setting a server up

Expand Down Expand Up @@ -71,6 +72,10 @@ does not protect any port you publish from another container yourself.
- **Nothing is interpreted by a shell on your machine.** `ssh` is started with an argument
list. On the server, every value placed in a command is validated against a strict
pattern or single-quoted.
- **A remote build opens no port.** The server's Docker is reached through a socket in a
directory only you can enter, and from Windows through a named pipe only your account
can write to. Docker on the server is root on the server, so nothing else on your
machine, a web page in your browser included, is given a way to it.
- **`nextship` is root-equivalent.** Membership of the `docker` group lets a user start a
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
Expand Down Expand Up @@ -108,9 +113,11 @@ 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
that was killed is reported with its owner after 30 minutes and never removed
automatically: confirm nothing is running, then remove
minutes, and the journal says so under `nextship-watchdog`. A deployment or rollback
stopped with Ctrl+C, by closing its terminal, or by `kill` gives its lock back before it
exits. One that was ended some other way, a lost power supply or `kill -9`, can still
leave its lock: that is reported with its owner after 30 minutes and never removed
automatically. Confirm nothing is running, then remove
`/etc/nextship/apps/<name>/lock` as the `nextship` user.

**Reboots.** Every app container runs with `--restart unless-stopped`, Docker is enabled at
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/build-attempts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ function builders(answers: Array<boolean | undefined>) {
const builder: ImageBuilder & { closed: number } = {
dockerHost: `unix:///tmp/forward-${opened.length}.sock`,
cacheScope: null,
warning: null,
closed: 0,
close: async () => {
builder.closed++
Expand Down
Loading
Loading