8 Commits

Author SHA1 Message Date
0xJacky
9b1433318c fix(demo): recover a container the platform still calls healthy
An instance sat 'running' for nineteen hours with nothing bound to 8080.
Every request got the platform's own "container is not listening" error,
getState() reported 'healthy' throughout, and the demo never came back.

ready() returned early on a healthy state, so the only path that starts a
container was unreachable exactly when the container needed starting. The
platform's view of a container is not the truth, and this code treated it
as if it were.

A container that reports healthy without serving for a full boot window is
now stopped so the next request starts a fresh one, escalating to a kill if
the stop changes nothing. Deliberately slow to trigger and rate limited:
stopping a container that is merely slow to boot is how an earlier version
of this file put the loading page into a permanent refresh loop, so the
decision lives in a pure function with tests that assert two recoveries can
never land closer together than one cooldown.

Also stops the same outage from generating its own load. The loading page
polled /__demo/status forever, and each poll probed the container twice —
once via status(), once via ready() — so every open tab added two platform
error events per second to a container that was already down. status() now
drives the start itself, probes are cached for a couple of seconds, and the
page stops polling after five minutes instead of never.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 08:52:28 +08:00
0xJacky
a45f250169 fix(ci): stop stale images being redeployed over newer ones
The demo was serving an image five commits old even though CI had deployed the
current one. Two causes, and the first was mine.

Every manual deploy pasted a literal image SHA, and I kept reusing the same one
while iterating on Worker code — quietly rolling the container back over what
CI had already deployed. deploy-published.sh now defaults to the image for the
current commit, so the common case needs no SHA at all, and it verifies the
image exists before deploying. That guard earned its keep immediately: the very
next invocation caught a hand-typed SHA that did not correspond to any commit.

Only an explicit "manifest unknown" is fatal. Reaching Docker Hub fails
intermittently with a transport EOF, and treating that as a missing image would
block deploys on a network blip, so other errors warn and continue — Cloudflare
rejects a genuinely missing image clearly enough on its own.

Second cause: build.yml is path-filtered and cloudflare/** was not listed, so a
Worker-only change never reached deploy-demo. The Worker would sit undeployed
until some unrelated commit happened to trigger a build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:30:15 +08:00
0xJacky
ce6033de69 fix(demo): wait for the app, not just the port in front of it
Right after a deploy the loading page vanished while the container was still
starting, and the visitor got nginx's raw 502.

The readiness signal was wrong. @cloudflare/containers decides a port is ready
by fetching it and treating any HTTP response as success — only a connection
error retries. nginx binds 8080 the instant it starts, seconds before nginx-ui
is listening on 9000, so it answers 502 and the container is declared healthy
while nothing behind it can serve.

Probe the application instead: readiness now also requires GET /healthz to come
back 200. The result is cached once seen, since nginx-ui stays up for the
container's life, and cleared in onStop so a restarted container is not assumed
good.

Verified locally by stopping the container and sampling every 400ms through the
restart: no raw 502, and the Worker logged "nginx is up but the app is not
serving yet: 502" twice — the exact window that used to leak. Then verified in
production by forcing a recycle: the loading page covered the whole cold start.

Also folds restore() into recycle(); they had drifted into two identical
implementations with a doc comment stranded between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:42:26 +08:00
0xJacky
871c597e15 feat(demo): let Cloudflare own the demo's DNS record
The pre-existing A record for demo.nginxui.com has been removed, so the
hostname can now be attached as a custom domain rather than as a zone route
overlaying someone else's record.

This matters beyond tidiness: a route only works while a proxied DNS record
happens to exist, and that record was outside this repository. A custom domain
is created and maintained by wrangler from this config, so the whole routing
setup is reproducible from a clean checkout instead of depending on manual
dashboard state.

The zone route created earlier has been deleted, so exactly one mechanism now
serves the hostname.

Verified after propagation: demo.nginxui.com resolves, serves the SPA, reports
a healthy container, and still lets a base64url-encoded log path through to the
application.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:04:33 +08:00
0xJacky
6d8b9893e9 fix(ci): make the demo deploy job actually work
Two faults, both mine, both found only by running the deploy path rather than
just the substitution logic inside it.

The generated config was written to a temp directory, but wrangler resolves
`main` relative to the config file — so it went looking for /tmp/src/index.ts
and failed with "entry-point file not found". Generate it beside
wrangler.jsonc instead.

The route was declared as a custom domain, which asks Cloudflare to take over
the hostname's DNS record. It refuses while an ordinary A record exists, so
every deploy reported a partially-updated trigger config and exited non-zero —
enough to fail the job even though the Worker deployed fine. What actually
serves demo.nginxui.com is a zone route overlaying the existing proxied record,
so declare that. The config now matches reality and the deploy exits 0.

Verified by running the script against the image CI published: exit 0, the
demo still serves, and a base64url-encoded log path still reaches the
application rather than the WAF block page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:00:26 +08:00
0xJacky
407978565a ci(demo): build the demo image once and deploy it to Cloudflare
Pushing to dev built and published the demo image but never updated the running
demo — wrangler.jsonc builds from ../demo.Dockerfile, so deploying meant a
person running `bun run deploy` locally. The published image and the deployed
one had no connection.

Close the loop: deploy-demo runs after docker-build and points the Worker at
the image that job just pushed. Nothing is rebuilt, so the step is a Worker
update measured in seconds. It also sidesteps the flaky
registry.cloudflare.com pushes seen when deploying from a laptop, because the
image is already on Docker Hub and never needs re-uploading.

That needs an immutable tag. Cloudflare pins a container application to a
specific image reference, so redeploying against :latest would leave instances
on the old bytes; deploy-published.sh refuses a :latest argument outright
rather than silently no-op'ing.

Also drop the demo image to linux/amd64. It was built for six platforms under
QEMU — the slowest part of the whole workflow — and Cloudflare Containers only
ever runs amd64, so five of them were emulated and discarded. The production
image is unaffected and still multi-arch.

deploy-published.sh substitutes the image field rather than keeping a second
config file, so wrangler.jsonc stays the single source of truth and cannot
drift. The substitution asserts it matched exactly once, so a future edit to
that block fails loudly instead of deploying something unintended.

Requires CF_API_TOKEN to carry Workers Scripts and Containers write scopes; it
currently only needs Pages for the docs deploy. Until it does, this one job
fails and nothing else is affected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 12:41:38 +08:00
0xJacky
6dd234f146 fix(demo): stop the loading page reloading forever
Two faults, both surfacing as a page that refreshes without end.

The Worker checked container readiness and then proxied, which are not
atomic: the idle timeout can reap the container in between, leaving the
Durable Object reporting a state that is no longer true. The platform
then answers with its own "not listening in the TCP address" error,
which reached the visitor verbatim instead of the loading page.

Detecting that is awkward — it arrives as a plain-text 5xx body rather
than a throw, so there is nothing to catch and no status or header that
separates it from an application error. Match the message, treat it as
not-ready, and fall through to the loading path.

The first attempt at this also called recycle() on detection, which was
worse than the bug: recycle() stops the container, and by then ready()
had already started a new boot, so every request killed the container
that was trying to start. Nothing could ever reach healthy and the page
refreshed indefinitely. Re-arm the boot, never stop it.

Independently, harden the page itself. It reloads when the container
reports ready, so a container that flaps made it reload endlessly. Cap
reloads within a two-minute window and show an explanation with a manual
retry instead. The window matters: spaced-out reloads are ordinary cold
starts across a long session and must not accumulate into a false
positive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:20:33 +08:00
0xJacky
df285c4f8e feat(demo): run the public demo on Cloudflare Containers
Moves demo.nginxui.com off a VPS and onto a Worker-fronted container
that sleeps when idle.

The Worker never awaits the boot. It checks container state and, when
not ready, starts it in the background and immediately returns a
self-contained loading page at the requested URL — 200, no redirect, so
deep links survive and no redirect loop is possible. API and WebSocket
requests get 503 with Retry-After instead of HTML they cannot parse. The
page is fully inline because nothing under /assets is reachable while
the container that serves it is still starting.

Disk is ephemeral, so a scheduled stop IS the restore; a daily cron
handles the case where nobody idled it out. SIGTERM rather than a kill,
because nginx-ui holds an open SQLite handle.

The image needed rebuilding around four platform constraints, each found
by reproducing them locally rather than reading docs:

  1. s6-overlay cannot run. Its preinit chowns /run and its suexec calls
     setgid; neither CAP_CHOWN nor CAP_SETGID is granted, and s6 exits
     111 before anything starts. Replaced with a plain entrypoint.
  2. A `USER` directive prevents scheduling entirely — the instance sits
     'inactive' and never gets a location. Runs as root instead.
  3. Root is not all-powerful there. With capabilities dropped there is
     no CAP_DAC_OVERRIDE, so root obeys file permissions; chowning paths
     to `nginx` locked root out of its own filesystem.
  4. /run is a fresh tmpfs at runtime, so directories created at build
     time are gone and nginx-ui cannot bind its handover socket. The
     entrypoint creates them on start.

Cluster peers run as extra nginx-ui processes in the same container on
loopback rather than as separate containers. Node traffic then never
crosses the Worker boundary, never counts as an in-flight request
holding the container awake, and needs no outbound interception.

Also drops NGINX_UI_OFFICIAL_DOCKER: it enabled a Docker socket
self-check that can only fail here, logged OTA cleanup errors on every
boot, and defaulted RestartCmd to `nginx -s stop` — which assumes the s6
supervisor this image no longer has, so restarting nginx from the UI
would have killed the container. The entrypoint supervises nginx
instead.

/__demo/bootlog keeps nginx serving the startup log even when nginx-ui
is down; it is how constraint 4 was found, and without container stdout
it is the only window in. /__demo/recycle forces a fresh container after
a config change that only applies at start.

Image: 509 MB -> 425 MB (105 MB compressed, which is what Cloudflare
distributes), mostly from building with -trimpath -ldflags "-s -w",
which the release workflow already did and this path did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 00:34:28 +08:00