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>