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>
This commit is contained in:
0xJacky
2026-08-02 00:34:28 +08:00
parent 4c0802ed1d
commit df285c4f8e
16 changed files with 1088 additions and 47 deletions

3
.gitignore vendored
View File

@@ -29,3 +29,6 @@ log-index/
GeoLite2-City.mmdb
*.log
.claude/settings.local.json
# Cloudflare Workers local state (miniflare cache, durable object storage)
cloudflare/.wrangler/

30
cloudflare/build-binary.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/sh
# Produce the linux/amd64 nginx-ui binary that demo.Dockerfile copies in.
#
# Cross-compiling from macOS needs a CGO toolchain (the SQLite driver), so this
# builds inside a Linux container rather than on the host. The frontend must be
# built first: app/dist is embedded into the binary at compile time.
set -eu
repo_root="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
if [ ! -f app/dist/index.html ]; then
echo "app/dist is missing; building the frontend first" >&2
bun run build
fi
echo "building nginx-ui for linux/amd64 (this takes a few minutes on a cold cache)"
# Same flags as the release build in .github/workflows/build.yml, plus -s.
# An unstripped binary is ~166 MB, which dominates the image and therefore the
# cold start, since Cloudflare distributes the image before an instance runs.
docker run --rm --platform linux/amd64 \
-v "$repo_root":/src -w /src \
-v "$HOME/go/pkg/mod":/go/pkg/mod \
-e GOWORK=off -e CGO_ENABLED=1 -e GOOS=linux -e GOARCH=amd64 \
golang:1.26-trixie \
go build -trimpath -tags=jsoniter \
-ldflags "-s -w -X 'github.com/0xJacky/Nginx-UI/settings.buildTime=$(date +%s)'" \
-o /src/nginx-ui-linux-amd64/nginx-ui main.go
ls -la nginx-ui-linux-amd64/nginx-ui

205
cloudflare/bun.lock Normal file
View File

@@ -0,0 +1,205 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "nginx-ui-demo-worker",
"dependencies": {
"@cloudflare/containers": "^0.3.7",
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260801.1",
"typescript": "^5.9.2",
"wrangler": "^4.118.0",
},
},
},
"packages": {
"@cloudflare/containers": ["@cloudflare/containers@0.3.7", "", {}, "sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260730.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260730.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ=="],
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260730.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA=="],
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260730.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ=="],
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260730.1", "", { "os": "win32", "cpu": "x64" }, "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ=="],
"@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260801.1", "", {}, "sha512-XCv5xWi47WQOK0LpLa6997Mrpz8Ct+nZmp/M5Xp8Z4BFsarf7nYjkznGOcOoYK5m1GfbMFEEuQ2OIZnbIWoe9A=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="],
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@speed-highlight/core": ["@speed-highlight/core@1.2.18", "", {}, "sha512-Q5USMGPOLp/dpVE0EA11QGuFyLAccJDKvsrmfqY/ZD540x/3kKlwtvuUxMqzNrOsGE/H4VtXe75EWma0dj/0jA=="],
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"miniflare": ["miniflare@5.20260730.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.28.0", "workerd": "1.20260730.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-8/dspSXDshP6nSkCpjKO7BYc2qZoYSXm7iM+QxY7qJyJpAB3onnQSaiu0cvKJlfuMGwULl55hG69FJCcCMXU1Q=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
"workerd": ["workerd@1.20260730.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260730.1", "@cloudflare/workerd-darwin-arm64": "1.20260730.1", "@cloudflare/workerd-linux-64": "1.20260730.1", "@cloudflare/workerd-linux-arm64": "1.20260730.1", "@cloudflare/workerd-windows-64": "1.20260730.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA=="],
"wrangler": ["wrangler@4.118.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260730.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260730.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260730.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-9pkBw/b8zWqGx2S+oLhgHMR1M/4VOE8SynUFABnGWiSFGlcOQ4xiI/B71Xf66RYP2xzngU37IQFPtUruij3lYw=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
}
}

20
cloudflare/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "nginx-ui-demo-worker",
"private": true,
"type": "module",
"scripts": {
"build:binary": "sh ./build-binary.sh",
"dev": "sh ./build-binary.sh && wrangler dev -c wrangler.dev.jsonc",
"deploy": "sh ./build-binary.sh && wrangler deploy",
"typecheck": "tsc --noEmit",
"types": "wrangler types"
},
"dependencies": {
"@cloudflare/containers": "^0.3.7"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260801.1",
"typescript": "^5.9.2",
"wrangler": "^4.118.0"
}
}

252
cloudflare/src/index.ts Normal file
View File

@@ -0,0 +1,252 @@
import { Container, getContainer } from '@cloudflare/containers'
import { loadingPage } from './loading'
interface Env {
NGINX_UI_DEMO: DurableObjectNamespace<NginxUiDemo>
/**
* Shared secret for the manual recycle endpoint. Unset means the endpoint is
* disabled, which is the right default: no secret, no lever.
* bunx wrangler secret put DEMO_ADMIN_TOKEN
*/
DEMO_ADMIN_TOKEN?: string
}
/**
* The demo runs as a single shared instance rather than one per visitor.
*
* Everyone must see the same nginx configuration: a visitor who edits a site
* and reloads has to land back on the container that holds the edit. getRandom
* would scatter them across instances and make the demo look broken.
*/
// Durable Object state persists across container-application deletes, and a DO
// that still references a deleted application never binds a new instance. Bump
// this to hand the Worker a clean Durable Object when that happens.
const INSTANCE = 'demo-v2'
/** Worker-owned path, never forwarded to the container. */
const STATUS_PATH = '/__demo/status'
/**
* Container-served path that must reach nginx even while the container is
* still reported as unhealthy — it is how a failed boot gets diagnosed, so
* gating it behind readiness would defeat it.
*/
const BOOT_LOG_PATH = '/__demo/bootlog'
/** Worker-owned, secret-gated: stop the container so the next request starts it fresh. */
const RECYCLE_PATH = '/__demo/recycle'
export class NginxUiDemo extends Container<Env> {
// nginx inside the container listens here; it proxies to nginx-ui on 9000.
defaultPort = 8080
// Long enough that a visitor reading the docs mid-session does not get
// dropped, short enough that an idle demo is not billed all night.
sleepAfter = '20m'
// The SPA's own health endpoint (router/routers.go). The default "ping" path
// would 404 and delay readiness.
pingEndpoint = 'localhost/healthz'
// Note: the WebSocket origin allowlist lives in resources/demo/app.ini, not
// in envVars here. envVars are applied at container start, so they do not
// reach an already-running instance on deploy — and a value baked into the
// image ships and rolls out with it. Serving this Worker on a hostname other
// than the one in app.ini means adding that origin there too, or every
// WebSocket upgrade will be rejected.
/**
* Boot in progress, if any. Held on the instance so concurrent requests
* during a cold start share one startup rather than racing several.
*/
private booting?: Promise<void>
/**
* Report whether the container can serve, kicking off a start if not.
*
* Deliberately does not await the boot: the caller returns a loading page
* immediately instead of holding the request open for several seconds, which
* is what produces a white screen.
*/
/** Current container state, for the status endpoint and for debugging. */
async status(): Promise<{ ready: boolean, status: string, exitCode?: number }> {
const state = await this.getState()
return {
ready: state.status === 'healthy',
status: state.status,
// exitCode is only present on the stopped-with-code variant.
...('exitCode' in state ? { exitCode: state.exitCode as number } : {}),
}
}
async ready(): Promise<boolean> {
const state = await this.getState()
if (state.status === 'healthy') {
return true
}
console.log(`container not ready yet: status=${state.status}`)
this.booting ??= this.startAndWaitForPorts()
.catch((err: unknown) => {
console.error('demo container failed to start', err)
})
.finally(() => {
this.booting = undefined
})
return false
}
/**
* Return the demo to its pristine state.
*
* Container disk is ephemeral, so stopping IS the restore: the next start
* comes up from the image with the seeded database and configs back in
* place. SIGTERM rather than a kill, because s6-overlay shuts nginx and
* nginx-ui down in order and nginx-ui holds an open SQLite handle.
*/
/**
* Stop the container so the next request starts it fresh.
*
* Needed after changing envVars: they are applied when the container starts,
* so a deploy alone leaves the running instance on the old environment.
*/
async recycle(): Promise<string> {
const state = await this.getState()
if (state.status === 'stopped' || state.status === 'stopped_with_code') {
return 'already-stopped'
}
await this.stop()
return 'stopped'
}
async restore(): Promise<'stopped' | 'already-stopped'> {
const state = await this.getState()
if (state.status === 'stopped' || state.status === 'stopped_with_code') {
// Most days the idle timeout will already have done this.
return 'already-stopped'
}
await this.stop()
return 'stopped'
}
override onError(error: unknown): Response {
console.error('demo container error', error)
return new Response('The demo container failed to start.', {
status: 502,
headers: { 'content-type': 'text/plain; charset=utf-8' },
})
}
}
/** A navigation request is one where showing a loading page makes sense. */
function wantsDocument(request: Request): boolean {
if (request.method !== 'GET') {
return false
}
const accept = request.headers.get('accept') ?? ''
return accept.includes('text/html')
}
function isWebSocketUpgrade(request: Request): boolean {
return (request.headers.get('upgrade') ?? '').toLowerCase() === 'websocket'
}
/**
* Stamp the public scheme and host onto a request before it reaches the
* container.
*
* The Worker-to-container hop is plain HTTP, so without this nginx derives
* `http://<host>` while the browser sent `Origin: https://<host>`. Nginx UI's
* WebSocket origin check compares the two and rejects every upgrade — the
* terminal, the log stream and the cluster monitor all fail with nothing in the
* logs to explain it.
*/
function withForwardedHeaders(request: Request, url: URL): Request {
// A WebSocket upgrade is passed through untouched. Reconstructing the
// request drops the upgrade in the Workers runtime, and the handshake then
// fails with no diagnostic anywhere. The origin problem those headers would
// have solved is handled by NGINX_UI_HTTP_WEBSOCKET_TRUSTED_ORIGINS instead.
if (isWebSocketUpgrade(request)) {
return request
}
const headers = new Headers(request.headers)
headers.set('X-Forwarded-Proto', url.protocol.replace(':', ''))
headers.set('X-Forwarded-Host', url.host)
return new Request(request, { headers })
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
const container = getContainer(env.NGINX_UI_DEMO, INSTANCE)
// Answered by the Worker so the loading page has something to poll that
// does not depend on the container being up.
if (url.pathname === STATUS_PATH) {
const status = await container.status()
if (!status.ready) {
// Kick off a boot so polling the status page actually makes progress.
await container.ready()
}
return Response.json(status, {
headers: { 'cache-control': 'no-store' },
})
}
// Force a fresh container, for when a config change only takes effect at
// container start (envVars) rather than at deploy.
if (url.pathname === RECYCLE_PATH) {
const supplied = request.headers.get('x-demo-admin-token')
if (!env.DEMO_ADMIN_TOKEN || supplied !== env.DEMO_ADMIN_TOKEN) {
return new Response('Not found', { status: 404 })
}
return Response.json({ outcome: await container.recycle() })
}
if (url.pathname === BOOT_LOG_PATH) {
// Deliberately unguarded: nginx answers this one even when nginx-ui is
// down, which is precisely when it is worth reading.
await container.ready()
return container.fetch(withForwardedHeaders(request, url))
}
if (await container.ready()) {
// fetch(), not containerFetch(): only fetch() carries WebSocket upgrades,
// which the terminal, log stream and cluster monitor all rely on.
return container.fetch(withForwardedHeaders(request, url))
}
if (wantsDocument(request) && !isWebSocketUpgrade(request)) {
// 200 at the originally requested URL, never a redirect: the visitor's
// deep link survives, and there is structurally no redirect loop.
return new Response(loadingPage(), {
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
},
})
}
// XHR, assets and WebSocket upgrades get a normal retry signal instead of
// an HTML page they cannot parse.
return new Response('The demo is starting up.', {
status: 503,
headers: {
'retry-after': '3',
'cache-control': 'no-store',
'content-type': 'text/plain; charset=utf-8',
},
})
},
async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
const container = getContainer(env.NGINX_UI_DEMO, INSTANCE)
const outcome = await container.restore()
console.log(`scheduled demo restore: ${outcome}`)
},
}

106
cloudflare/src/loading.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* The page shown while the demo container is booting.
*
* Must be completely self-contained. During a cold start nothing under
* /assets/* is reachable, because the SPA is served from inside the container
* that has not started yet — so a single external stylesheet, font or image
* reference would leave the loading page itself broken.
*/
const SPINNER = `<svg class="spinner" viewBox="0 0 50 50" aria-hidden="true">
<circle cx="25" cy="25" r="20" fill="none" stroke-width="4" />
</svg>`
export function loadingPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<title>Starting Nginx UI demo…</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center;
font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #fff; color: #1f2328;
}
@media (prefers-color-scheme: dark) {
body { background: #141414; color: #e6e6e6; }
.hint { color: #8b949e !important; }
.bar { background: #262626 !important; }
}
.card { width: min(420px, calc(100vw - 48px)); text-align: center; padding: 24px; }
.spinner { width: 44px; height: 44px; animation: rotate 1.6s linear infinite; }
.spinner circle {
stroke: #1677ff; stroke-linecap: round;
animation: dash 1.4s ease-in-out infinite;
}
@keyframes rotate { 100% { transform: rotate(360deg); } }
@keyframes dash {
0% { stroke-dasharray: 1, 150; stroke-dashoffset: 0; }
50% { stroke-dasharray: 90, 150; stroke-dashoffset: -24; }
100% { stroke-dasharray: 90, 150; stroke-dashoffset: -124; }
}
h1 { font-size: 17px; font-weight: 600; margin: 20px 0 8px; }
.hint { font-size: 13px; color: #656d76; margin: 0; }
.bar { margin-top: 22px; height: 3px; border-radius: 3px; background: #f0f0f0; overflow: hidden; }
.bar span {
display: block; height: 100%; width: 35%; border-radius: 3px; background: #1677ff;
animation: slide 1.5s ease-in-out infinite;
}
@keyframes slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(340%); }
}
.slow { margin-top: 18px; font-size: 12.5px; color: #656d76; display: none; }
</style>
</head>
<body>
<main class="card">
${SPINNER}
<h1>Waking the demo up</h1>
<p class="hint">This instance sleeps when nobody is using it. First request takes a few seconds.</p>
<div class="bar"><span></span></div>
<p class="slow" id="slow">Still starting. This can take up to a minute after a new deploy.</p>
</main>
<script>
(function () {
var started = Date.now();
var delay = 700;
setTimeout(function () {
var el = document.getElementById('slow');
if (el) el.style.display = 'block';
}, 12000);
function poll() {
fetch('/__demo/status', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : { ready: false }; })
.then(function (s) {
if (s && s.ready) {
// Reload rather than navigate, so the deep link the visitor arrived
// on is preserved.
location.reload();
return;
}
schedule();
})
.catch(schedule);
}
function schedule() {
// Back off gently, capped, so a long boot does not hammer the edge.
delay = Math.min(delay * 1.3, 4000);
setTimeout(poll, delay);
}
setTimeout(poll, delay);
})();
</script>
</body>
</html>`
}

15
cloudflare/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022"],
"module": "es2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,49 @@
{
// Local-only override: builds the container from this working tree instead
// of pulling the published image, so `wrangler dev` exercises unreleased
// changes. Not used by `wrangler deploy`.
//
// demo.Dockerfile expects a prebuilt linux/amd64 binary at
// nginx-ui-linux-amd64/nginx-ui in the build context; produce it with:
//
// docker run --rm --platform linux/amd64 -v "$PWD":/src -w /src \
// -e GOWORK=off -e CGO_ENABLED=1 -e GOOS=linux -e GOARCH=amd64 \
// golang:1.26-trixie go build -o /src/nginx-ui-linux-amd64/nginx-ui main.go
//
// bunx wrangler dev -c wrangler.dev.jsonc
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "nginx-ui-demo-dev",
"main": "src/index.ts",
"compatibility_date": "2026-07-01",
"compatibility_flags": ["nodejs_compat"],
"containers": [
{
"class_name": "NginxUiDemo",
"image": "../demo.Dockerfile",
"image_build_context": "..",
"image_vars": {
"TARGETOS": "linux",
"TARGETARCH": "amd64"
},
"instance_type": "basic",
"max_instances": 1
}
],
"durable_objects": {
"bindings": [
{
"name": "NGINX_UI_DEMO",
"class_name": "NginxUiDemo"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["NginxUiDemo"]
}
]
}

72
cloudflare/wrangler.jsonc Normal file
View File

@@ -0,0 +1,72 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "nginx-ui-demo",
"main": "src/index.ts",
"compatibility_date": "2026-07-01",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"enabled": true
},
// Keep the workers.dev URL alive alongside the custom domain: it is the only
// way to reach the demo when the custom domain's DNS is being changed.
"workers_dev": true,
// demo.nginxui.com must already exist as a zone on this account. Wrangler
// takes over the DNS record when custom_domain is true.
"routes": [
{
"pattern": "demo.nginxui.com",
"custom_domain": true
}
],
"containers": [
{
"class_name": "NginxUiDemo",
// Built and pushed to Cloudflare's own registry by `wrangler deploy`,
// rather than pulled from Docker Hub. That keeps the deployed image and
// this working tree in lockstep, and avoids Docker Hub pull limits.
//
// demo.Dockerfile expects a prebuilt linux/amd64 binary at
// nginx-ui-linux-amd64/nginx-ui; `bun run deploy` produces it first.
"image": "../demo.Dockerfile",
"image_build_context": "..",
"image_vars": {
"TARGETOS": "linux",
"TARGETARCH": "amd64"
},
// Measured: the container settles around 550 MiB with three nginx-ui
// instances plus nginx, so 1 GiB is the smallest tier that fits.
"instance_type": "basic",
// Not 1. The platform keeps a provisioned instance of its own, and with a
// cap of 1 that instance consumes the whole quota — the Durable Object's
// instance then sits 'inactive' forever and the container never starts.
// Routing is a singleton by instance ID, so only one is ever claimed.
"max_instances": 3
}
],
"durable_objects": {
"bindings": [
{
"name": "NGINX_UI_DEMO",
"class_name": "NginxUiDemo"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["NginxUiDemo"]
}
],
// Daily restore. The container is ephemeral, so stopping it is the restore.
// 19:00 UTC is roughly 03:00 in CST, the quietest window for this audience.
"triggers": {
"crons": ["0 19 * * *"]
}
}

View File

@@ -1,40 +1,36 @@
# CGO_ENABLED=1 GOOS=linux CC=x86_64-unknown-linux-gnu-gcc CXX=x86_64-unknown-linux-gnu-g++ GOARCH=amd64 go build -ldflags "-X 'github.com/0xJacky/Nginx-UI/settings.buildTime=$(date +%s)'" -o nginx-ui -v main.go
# Demo image for the public instance on Cloudflare Containers.
#
# Deliberately does NOT use s6-overlay, unlike the production Dockerfile.
# s6's preinit chowns /run and its suexec calls setgid; Cloudflare Containers
# grant neither CAP_CHOWN nor CAP_SETGID, so s6 exits 111 before anything
# starts. resources/demo/entrypoint.sh starts the same processes directly.
#
# Build the binary first (see cloudflare/build-binary.sh):
# GOWORK=off CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -trimpath \
# -tags=jsoniter -ldflags "-s -w" -o nginx-ui-linux-amd64/nginx-ui main.go
ARG NGINX_VERSION=latest
FROM nginx:${NGINX_VERSION}
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
ARG S6_OVERLAY_VERSION=3.2.1.0
WORKDIR /app
EXPOSE 80
# 8080 rather than 80: this image runs unprivileged (see USER below) and cannot
# bind a port under 1024.
EXPOSE 8080
ENV DEBIAN_FRONTEND=noninteractive
ENV NGINX_UI_WORKING_DIR=/var/run/
ENV NGINX_UI_WORKING_DIR=/var/run/nginx-ui
# Deliberately NOT setting NGINX_UI_OFFICIAL_DOCKER. It would enable a Docker
# socket self-check that can only fail here (no socket is mounted), run OTA
# container cleanup that logs errors on every boot, and default RestartCmd to
# `nginx -s stop` — which assumes an s6 supervisor that this image no longer
# has. resources/demo/app.ini sets RestartCmd explicitly instead, and
# entrypoint.sh does the supervising.
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends wget xz-utils logrotate nginx-module-geoip \
&& apt-get install -y --no-install-recommends logrotate \
&& rm -rf /var/lib/apt/lists/*
RUN case "${TARGETARCH}/${TARGETVARIANT}" in \
"amd64/"*) S6_ARCH="x86_64" ;; \
"arm64/"*) S6_ARCH="aarch64" ;; \
"arm/v7"*) S6_ARCH="arm" ;; \
"arm/v6"*) S6_ARCH="arm" ;; \
"arm/v5"*) S6_ARCH="arm" ;; \
"riscv64/"*) S6_ARCH="riscv64" ;; \
*) echo "Unsupported arch: ${TARGETARCH}/${TARGETVARIANT}" && exit 1 ;; \
esac && \
wget -O /tmp/s6-overlay-noarch.tar.xz https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz && \
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && \
wget -O /tmp/s6-overlay-${S6_ARCH}.tar.xz https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz && \
tar -C / -Jxpf /tmp/s6-overlay-${S6_ARCH}.tar.xz && \
rm -f /tmp/s6-overlay-noarch.tar.xz /tmp/s6-overlay-${S6_ARCH}.tar.xz
# register nginx service
COPY resources/docker/nginx.run /etc/s6-overlay/s6-rc.d/nginx/run
RUN echo 'longrun' > /etc/s6-overlay/s6-rc.d/nginx/type && \
touch /etc/s6-overlay/s6-rc.d/user/contents.d/nginx
RUN mkdir -p /usr/local/etc \
&& mkdir /etc/nginx/sites-available \
&& mkdir /etc/nginx/sites-enabled \
@@ -42,16 +38,6 @@ RUN mkdir -p /usr/local/etc \
&& mkdir /etc/nginx/streams-enabled \
&& cp -r /etc/nginx /usr/local/etc/nginx
# init config
COPY resources/docker/init-config.up /etc/s6-overlay/s6-rc.d/init-config/up
COPY resources/docker/init-config.sh /etc/s6-overlay/s6-rc.d/init-config/init-config.sh
RUN chmod +x /etc/s6-overlay/s6-rc.d/init-config/init-config.sh && \
echo 'oneshot' > /etc/s6-overlay/s6-rc.d/init-config/type && \
touch /etc/s6-overlay/s6-rc.d/user/contents.d/init-config && \
mkdir -p /etc/s6-overlay/s6-rc.d/nginx/dependencies.d && \
touch /etc/s6-overlay/s6-rc.d/nginx/dependencies.d/init-config
# copy demo config
COPY resources/demo/ojbk.me /etc/nginx/sites-available/ojbk.me
COPY ["resources/demo/Prime Sponsor", "/etc/nginx/sites-available/Prime Sponsor"]
@@ -61,15 +47,15 @@ RUN ln -s "/etc/nginx/sites-available/Prime Sponsor" \
COPY resources/demo/app.ini /etc/nginx-ui/app.ini
COPY resources/demo/demo.db /etc/nginx-ui/database.db
# register nginx-ui service
COPY resources/docker/nginx-ui.run /etc/s6-overlay/s6-rc.d/nginx-ui/run
RUN echo 'longrun' > /etc/s6-overlay/s6-rc.d/nginx-ui/type && \
touch /etc/s6-overlay/s6-rc.d/user/contents.d/nginx-ui
# copy nginx config
COPY resources/docker/nginx.conf /etc/nginx/nginx.conf
COPY resources/docker/nginx-ui.conf /etc/nginx/conf.d/nginx-ui.conf
# The demo uses its own nginx.conf / nginx-ui.conf rather than the ones under
# resources/docker: no `user` directive, temp paths under /tmp, and port 8080,
# all so the container can run unprivileged. resources/docker/* stays as-is for
# the production image.
COPY resources/demo/nginx.conf /etc/nginx/nginx.conf
COPY resources/demo/nginx-ui.conf /etc/nginx/conf.d/nginx-ui.conf
COPY resources/demo/stub_status_nginx-ui.conf /etc/nginx/conf.d/stub_status_nginx-ui.conf
COPY resources/docker/nginx-ui.conf.known-hashes /usr/local/share/nginx-ui/nginx-ui.conf.known-hashes
# copy nginx-ui executable binary
COPY nginx-ui-$TARGETOS-$TARGETARCH$TARGETVARIANT/nginx-ui /usr/local/bin/nginx-ui
@@ -82,4 +68,30 @@ RUN rm -f /var/log/nginx/access.log && \
rm -f /var/log/nginx/error.log && \
touch /var/log/nginx/error.log
ENTRYPOINT ["/init"]
# extra nginx-ui instances so the cluster view has real peers to talk to,
# reachable over loopback instead of a second container
COPY resources/demo/setup-cluster-nodes.sh /usr/local/bin/setup-cluster-nodes.sh
RUN chmod +x /usr/local/bin/setup-cluster-nodes.sh && \
/usr/local/bin/setup-cluster-nodes.sh
COPY resources/demo/entrypoint.sh /usr/local/bin/demo-entrypoint.sh
RUN chmod +x /usr/local/bin/demo-entrypoint.sh
# Run unprivileged. Cloudflare Containers grant no privileged capabilities, and
# nothing here needs them once the writable paths are owned by the runtime user.
# Everything stays owned by root, and there is no USER directive. Both are
# forced by how Cloudflare Containers run this image:
#
# 1. With `USER nginx` the instance never got scheduled at all — it sat
# 'inactive' indefinitely, while an otherwise identical root image started
# normally.
# 2. Running as root does NOT mean unrestricted: every capability is dropped,
# so without CAP_DAC_OVERRIDE root obeys ordinary file permissions.
# Chowning these paths to `nginx` locked root out of its own filesystem and
# broke nginx's error log and nginx-ui's handover socket.
#
# So: root-owned, root-run, and no capabilities. The isolation comes from the
# capability set and the per-container VM, not from the uid.
RUN mkdir -p /var/cache/nginx /var/lib/nginx /var/run/nginx-ui /etc/nginx-ui
ENTRYPOINT ["/usr/local/bin/demo-entrypoint.sh"]

View File

@@ -40,6 +40,12 @@ Secret = 4a82fe17c5aab629007462b303c367d2863b7db28b95c8f869f2f815672d41d8
[http]
GithubProxy =
InsecureSkipVerify = false
# The Worker-to-container hop is plain HTTP, so nginx-ui derives http://<host>
# while the browser sends Origin: https://<host>, and every WebSocket upgrade
# is rejected. Naming the public origin is the supported fix.
# Deploying this image under a different hostname means adding it here, or
# setting NGINX_UI_HTTP_WEBSOCKET_TRUSTED_ORIGINS before the container starts.
WebSocketTrustedOrigins = https://demo.nginxui.com
[logrotate]
Enabled = false
@@ -51,20 +57,30 @@ AccessLogPath =
ErrorLogPath =
LogDirWhiteList = /var/log/nginx
ConfigDir =
PIDPath =
# Must match `pid` in resources/demo/nginx.conf. The demo runs unprivileged and
# cannot write the compiled-in default under /var/run, and without this override
# nginx.IsRunning() reports false and the whole dashboard shows nginx stopped.
PIDPath = /tmp/nginx.pid
TestConfigCmd =
ReloadCmd =
RestartCmd =
# entrypoint.sh restarts nginx when it exits, so stopping it is a restart.
RestartCmd = nginx -s stop
[nginx_log]
AdvancedIndexingEnabled = true
IndexingEnabled = true
[node]
Name =
Name = demo
Secret = 57D079F2-CA8B-412A-B5C0-FDA291C13391
InstanceID = 0e6c6b6a-3f1e-4a9f-8f8a-2f9c2b6d41d0
SkipInstallation = false
Demo = true
[site_check]
Enabled = true
Concurrency = 5
IntervalSeconds = 900
[openai]
Provider = openai
BaseUrl =

Binary file not shown.

View File

@@ -0,0 +1,108 @@
#!/bin/sh
# Demo container entrypoint.
#
# The production image uses s6-overlay, which cannot run here: s6's preinit
# chowns /run and its suexec calls setgid, and Cloudflare Containers grant
# neither CAP_CHOWN nor CAP_SETGID, so s6 exits 111 before anything starts.
#
# Note also that "runs as root" does not mean unrestricted. Every capability is
# dropped, so without CAP_DAC_OVERRIDE root obeys ordinary file permissions and
# nothing in the image may be owned by another user.
set -eu
: "${PEER_COUNT:=2}"
BOOT_LOG=/var/log/nginx/demo-boot.log
log() { echo "[entrypoint] $*" | tee -a "$BOOT_LOG"; }
mkdir -p /var/log/nginx
: > "$BOOT_LOG"
# Seed /etc/nginx on first boot, mirroring resources/docker/init-config.sh but
# without the s6 oneshot wrapper.
if [ -z "$(ls -A /etc/nginx 2>/dev/null)" ]; then
cp -rp /usr/local/etc/nginx/* /etc/nginx/
log "initialized /etc/nginx from the bundled template"
fi
# nginx creates its temp dirs lazily; make them now so a request in the first
# second does not race directory creation.
mkdir -p /tmp/nginx-client-body /tmp/nginx-proxy /tmp/nginx-fastcgi \
/tmp/nginx-uwsgi /tmp/nginx-scgi
# The working directories must be created at RUNTIME, not in the image.
# /run is a fresh tmpfs on Cloudflare Containers, so anything mkdir'd there at
# build time is gone by the time this script runs, and nginx-ui fails with
# "bind: no such file or directory" on its risefront handover socket.
mkdir -p /var/run/nginx-ui
n=2
while [ "$n" -le $((PEER_COUNT + 1)) ]; do
mkdir -p "/var/run/nginx-ui-node${n}"
n=$((n + 1))
done
# nginx comes up first and stays up for the container's whole life. Keeping it
# alive even when nginx-ui is down is deliberate: the platform health check and
# the boot log below stay reachable, so a failure is diagnosable instead of
# looking like a container that simply never started.
nginx -g "daemon off;" >> "$BOOT_LOG" 2>&1 &
NGINX_PID=$!
log "started nginx (pid $NGINX_PID)"
start_ui() {
name="$1"
workdir="$2"
config="$3"
NGINX_UI_WORKING_DIR="$workdir" nginx-ui --config "$config" >> "$BOOT_LOG" 2>&1 &
log "started $name (pid $!)"
}
# Peer nodes for the cluster view. Each needs its own working directory:
# risefront derives its handover socket path from it and would otherwise treat
# a peer as a hot-reload child of the primary.
n=2
while [ "$n" -le $((PEER_COUNT + 1)) ]; do
conf="/etc/nginx-ui-node${n}/app.ini"
if [ -f "$conf" ]; then
start_ui "nginx-ui-node${n}" "/var/run/nginx-ui-node${n}" "$conf"
fi
n=$((n + 1))
done
start_ui nginx-ui "${NGINX_UI_WORKING_DIR:-/var/run/nginx-ui}" /etc/nginx-ui/app.ini
shutdown() {
log "received termination signal"
kill -TERM "$NGINX_PID" 2>/dev/null || true
# nginx-ui holds an open SQLite handle; give every child a chance to close.
pkill -TERM nginx-ui 2>/dev/null || true
wait
exit 0
}
trap shutdown TERM INT
# Supervise nginx. The UI's "restart nginx" runs `nginx -s stop` (see
# RestartCmd in resources/demo/app.ini), so nginx exiting is a normal event
# that must bring it straight back rather than end the container.
#
# A crashed nginx-ui deliberately does NOT take the container down: it keeps
# serving 502 plus the boot log at /__demo/bootlog, and an opaque restart loop
# is far harder to debug than a 502.
restarts=0
while true; do
if kill -0 "$NGINX_PID" 2>/dev/null; then
sleep 5
continue
fi
restarts=$((restarts + 1))
if [ "$restarts" -gt 20 ]; then
log "nginx exited $restarts times; giving up so the platform recycles the container"
exit 1
fi
log "nginx exited; restarting it (attempt $restarts)"
sleep 1
nginx -g "daemon off;" >> "$BOOT_LOG" 2>&1 &
NGINX_PID=$!
done

View File

@@ -0,0 +1,44 @@
# Demo-only front proxy. Same as resources/docker/nginx-ui.conf except it
# listens on 8080 instead of 80, because the demo container runs unprivileged
# and cannot bind a port below 1024. The Cloudflare Container binding targets
# 8080 directly, so nothing external depends on the port number.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
map $http_x_forwarded_proto $forwarded_proto {
default $http_x_forwarded_proto;
'' $scheme;
}
map $http_x_forwarded_host $forwarded_host {
default $http_x_forwarded_host;
'' $http_host;
}
server {
listen 8080;
server_name localhost;
client_max_body_size 128M;
# Boot log, so a container whose nginx-ui failed to start can still be
# diagnosed from outside. Serving it needs no working backend, which is the
# entire point: this is reachable exactly when nothing else is.
location = /__demo/bootlog {
default_type text/plain;
alias /var/log/nginx/demo-boot.log;
}
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header X-Forwarded-Host $forwarded_host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://127.0.0.1:9000/;
}
}

51
resources/demo/nginx.conf Normal file
View File

@@ -0,0 +1,51 @@
# Demo-only nginx.conf, shaped by how Cloudflare Containers actually run:
# as root, but with every capability dropped.
#
# - `user root` because without CAP_CHOWN nginx cannot hand its temp
# directories to a different worker user, and it refuses to start when
# that chown fails
# - pid and temp paths under /tmp rather than /var/run
# - port 8080 in conf.d/nginx-ui.conf, since CAP_NET_BIND_SERVICE is gone too
#
# Note that dropping all capabilities means root is NOT all-powerful here: with
# no CAP_DAC_OVERRIDE it is subject to ordinary file permissions, so nothing in
# the image may be owned by another user.
user root;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
stream {
include /etc/nginx/streams-enabled/*;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
client_body_temp_path /tmp/nginx-client-body;
proxy_temp_path /tmp/nginx-proxy;
fastcgi_temp_path /tmp/nginx-fastcgi;
uwsgi_temp_path /tmp/nginx-uwsgi;
scgi_temp_path /tmp/nginx-scgi;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
gzip on;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}

View File

@@ -0,0 +1,58 @@
#!/bin/sh
# Build the peer nodes for the demo cluster view.
#
# Instead of running a second container, the demo runs extra nginx-ui processes
# inside the same container and points the cluster rows at 127.0.0.1. That keeps
# node-to-node traffic on loopback, so it never crosses the Cloudflare Worker
# boundary, never counts as an in-flight request (which would stop the container
# from ever sleeping), and needs no outbound interception.
#
# Two things must differ per instance:
# 1. the config directory, because the SQLite file is resolved as
# dir(configPath)/<DatabaseSettings.Name>.db
# 2. NGINX_UI_WORKING_DIR, because risefront derives its handover socket path
# from it (see prefix_dialer.go NewPrefixDialer). Sharing it makes the
# second process attach to the first as a hot-reload child and give up its
# own TCP port.
set -eu
: "${PEER_COUNT:=2}"
: "${BASE_CONFIG:=/etc/nginx-ui/app.ini}"
: "${BASE_DB:=/etc/nginx-ui/database.db}"
# Fixed instance IDs so the seeded node rows can reference them and so nothing
# rewrites app.ini on first boot.
peer_instance_id() {
case "$1" in
2) echo "1b7f4a52-9c3d-4e18-9a71-6d0f2c845b93" ;;
3) echo "2c8e5b63-ad4e-4f29-8b62-7e1a3d956c04" ;;
*) echo "00000000-0000-4000-8000-00000000000$1" ;;
esac
}
n=2
while [ "$n" -le $((PEER_COUNT + 1)) ]; do
# primary node holds 9000, so peer n takes 9000 + (n - 1)
port=$((8999 + n))
conf_dir="/etc/nginx-ui-node${n}"
run_dir="/var/run/nginx-ui-node${n}"
mkdir -p "$conf_dir" "$run_dir"
# Peers exist to populate the cluster view. They share the single nginx in
# this container, so leave the site prober and the log indexer to the
# primary node rather than paying for them three times.
sed -e "s|^Port .*|Port = ${port}|" \
-e "s|^Name .*|Name = demo-node-${n}|" \
-e "s|^InstanceID .*|InstanceID = $(peer_instance_id "$n")|" \
-e "s|^IndexingEnabled .*|IndexingEnabled = false|" \
"$BASE_CONFIG" > "${conf_dir}/app.ini"
sed -i "s|^Enabled = true|Enabled = false|" "${conf_dir}/app.ini"
cp "$BASE_DB" "${conf_dir}/database.db"
# resources/demo/entrypoint.sh discovers peers by looking for these config
# files, so there is nothing else to register.
echo "prepared demo peer node ${n} on port ${port} (${conf_dir})"
n=$((n + 1))
done