Feature 24: Admin PWA Hosting (#221)

Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #221.
This commit is contained in:
2026-09-24 12:45:17 -04:00
committed by approver_agent
parent 87e3f12f4c
commit ee6c1d6bc4
6 changed files with 233 additions and 12 deletions
+11
View File
@@ -21,3 +21,14 @@ NESTQUEST_OIDC_JWKS_URL=
# unset; on the API box set the LAN address the reverse proxy # unset; on the API box set the LAN address the reverse proxy
# forwards to, e.g. 10.60.1.14. # forwards to, e.g. 10.60.1.14.
NESTQUEST_BIND_ADDRESS= NESTQUEST_BIND_ADDRESS=
# Admin PWA build args (compose interpolation into the pwa service's
# VITE_* build args; baked into the bundle, so rebuild after changing).
# PUBLIC values, not secrets: the Authentik application is a PKCE client
# with no client secret. All three are required — the pwa image build
# fails when any is empty.
# NESTQUEST_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
# NESTQUEST_API_BASE_URL=https://nestquest.cubecraftlabs.com
NESTQUEST_AUTHENTIK_CLIENT_ID=
NESTQUEST_AUTHENTIK_ISSUER=
NESTQUEST_API_BASE_URL=
+110 -12
View File
@@ -8,7 +8,7 @@ given, never their content.
| Piece | Where | Real value | | Piece | Where | Real value |
| --- | --- | --- | | --- | --- | --- |
| API box (Docker host) | this repository's `deploy/` | `10.60.1.14`, port `8080` | | API box (Docker host) | this repository's `deploy/` | `10.60.1.14`, port `8080` (API), `8081` (admin PWA, section 10) |
| Reverse proxy / TLS | shared Traefik on `nukapi-03` | `10.60.1.33` | | Reverse proxy / TLS | shared Traefik on `nukapi-03` | `10.60.1.33` |
| Public hostname | Cloudflare DNS | `nestquest.cubecraftlabs.com` | | Public hostname | Cloudflare DNS | `nestquest.cubecraftlabs.com` |
| Identity | Authentik | `https://auth.cubecraftlabs.com` | | Identity | Authentik | `https://auth.cubecraftlabs.com` |
@@ -57,12 +57,13 @@ section 5 depend on uid 999):
## 2. Compose ## 2. Compose
`deploy/compose.yaml` defines two services and one named volume: `deploy/compose.yaml` defines three services and one named volume:
| Service | Image | Role | | Service | Image | Role |
| --- | --- | --- | | --- | --- | --- |
| `api` | `nestquest-api:latest` (built, section 1) | FastAPI service on `8080`, healthcheck on `/health` | | `api` | `nestquest-api:latest` (built, section 1) | FastAPI service on `8080`, healthcheck on `/health` |
| `litestream` | `litestream/litestream:0.5.17` | Continuous SFTP backup of the database (section 7); starts only once `api` is healthy | | `litestream` | `litestream/litestream:0.5.17` | Continuous SFTP backup of the database (section 7); starts only once `api` is healthy |
| `pwa` | `nestquest-pwa:latest` (built from `deploy/pwa`, section 10) | Admin PWA served by nginx on `8081`; independent of the other two |
- **Volume `nestquest-db`** is mounted at `/var/lib/nestquest` in both - **Volume `nestquest-db`** is mounted at `/var/lib/nestquest` in both
services. The database is `NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db` services. The database is `NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db`
@@ -96,14 +97,18 @@ repository. What it must contain:
| Router | Rule | Middleware | Service | | Router | Rule | Middleware | Service |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| panel | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `http://10.60.1.14:8080` | | `nestquest-panel` (priority 200) | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `nestquest-panel-lan`: `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `nestquest-api` → `http://10.60.1.14:8080` |
| admin (everything else: `/health`, `/docs`, `/openapi.json`, `/api/v1/admin/*`) | ``Host(`nestquest.cubecraftlabs.com`)`` | none — public; the API itself enforces Authentik JWTs (section 4) | `http://10.60.1.14:8080` | | `nestquest-admin` (priority 100: `/api/v1/admin`, `/health`, `/openapi.json`, `/docs`) | ``Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api/v1/admin`) \|\| Path(`/health`) \|\| Path(`/openapi.json`) \|\| PathPrefix(`/docs`))`` | `nestquest-headers` — no source restriction; the API itself enforces Authentik JWTs (section 4) | `nestquest-api` → `http://10.60.1.14:8080` |
| `nestquest-app` (priority 50: everything else — the admin PWA) | see section 10 | none at Traefik; nginx allow-lists the proxy | `nestquest-pwa` → `http://10.60.1.14:8081` |
Both routers use the HTTPS entrypoint `websecure` with `tls.certResolver: myresolver` All routers use the HTTPS entrypoint `websecure` with `tls.certResolver: myresolver`
(HTTP is redirected to HTTPS by the shared Traefik). The panel router's (HTTP is redirected to HTTPS by the shared Traefik). Priorities are set
rule is longer, so Traefik's default rule-length priority evaluates it explicitly so the order never depends on rule length: panel (200) above
before the catch-all admin router; if you set explicit priorities, keep admin (100), and the PWA's `nestquest-app` router (50) below both. The
panel higher. admin router is scoped to `/api/v1/admin`, `/health`, `/openapi.json`
and `/docs` — a bare ``Host(...)`` rule at a higher priority would
swallow `/` and every PWA route. Any other `/api/*` path matches no
router (the app router excludes `/api`), so Traefik answers `404`.
Reference shape, to rebuild the file if it is ever lost (entrypoint names Reference shape, to rebuild the file if it is ever lost (entrypoint names
must match the shared Traefik's static config on nukapi-03): must match the shared Traefik's static config on nukapi-03):
@@ -113,26 +118,42 @@ http:
routers: routers:
nestquest-panel: nestquest-panel:
rule: "Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)" rule: "Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)"
priority: 200
entryPoints: [websecure] entryPoints: [websecure]
middlewares: [nestquest-panel-allowlist] middlewares: [nestquest-panel-lan]
service: nestquest-api service: nestquest-api
tls: tls:
certResolver: myresolver certResolver: myresolver
nestquest-admin: nestquest-admin:
rule: "Host(`nestquest.cubecraftlabs.com`)" rule: "Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api/v1/admin`) || Path(`/health`) || Path(`/openapi.json`) || PathPrefix(`/docs`))"
priority: 100
entryPoints: [websecure] entryPoints: [websecure]
middlewares: [nestquest-headers]
service: nestquest-api service: nestquest-api
tls: tls:
certResolver: myresolver certResolver: myresolver
nestquest-app:
rule: "Host(`nestquest.cubecraftlabs.com`) && !PathPrefix(`/api`) && !Path(`/health`) && !Path(`/openapi.json`) && !PathPrefix(`/docs`)"
priority: 50
entryPoints: [websecure]
service: nestquest-pwa
tls:
certResolver: myresolver
middlewares: middlewares:
nestquest-panel-allowlist: nestquest-panel-lan:
ipAllowList: ipAllowList:
sourceRange: ["10.60.1.80/32"] sourceRange: ["10.60.1.80/32"]
nestquest-headers:
# headers middleware; copy its definition from the live file
services: services:
nestquest-api: nestquest-api:
loadBalancer: loadBalancer:
servers: servers:
- url: "http://10.60.1.14:8080" - url: "http://10.60.1.14:8080"
nestquest-pwa:
loadBalancer:
servers:
- url: "http://10.60.1.14:8081"
``` ```
**Certificates.** `myresolver` is the shared Traefik's ACME (Let's **Certificates.** `myresolver` is the shared Traefik's ACME (Let's
@@ -453,3 +474,80 @@ To obtain `$ADMIN_JWT`: sign in to the admin PWA as the user, open the
browser developer tools, and copy the session-storage value browser developer tools, and copy the session-storage value
`nestquest.access_token`. Tokens are short-lived and are credentials — `nestquest.access_token`. Tokens are short-lived and are credentials —
do not store them. do not store them.
## 10. Admin PWA hosting
The admin PWA (`admin/`, a Vite + React single-page app) is served from
the API box by the compose service **`pwa`**: nginx on container port
`80`, published on `${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8081` (on the API
box `10.60.1.14:8081`). Traefik sends every path of
`nestquest.cubecraftlabs.com` that is not the API to it.
**Image** — `deploy/pwa/Dockerfile`, built from the **repository root**
(it needs `admin/`); `deploy/pwa/Dockerfile.dockerignore` limits the
context to `admin/` (without `node_modules`, `dist` or `.env` files) and
`deploy/pwa/nginx.conf`.
- Stage 1, `node:22-alpine` (pinned by digest): `npm ci`, then
`npm run build` with the three `VITE_*` build args below. Vite bakes
them into the bundle, so **changing any of them needs a rebuild**. The
build fails if any is empty.
- Stage 2, `nginx:alpine` pinned to
`sha256:1ed1b0e1d7652937d6cbdaf4018c7b6fc009a7dd6c3047351e2eddda745de43f`
(nginx 1.31.6): the built `dist/` in `/usr/share/nginx/html` and
`deploy/pwa/nginx.conf` as `conf.d/default.conf`.
**Build args** — compose reads them from `deploy/.env` (documented in
`deploy/.env.example`). They are **public values, not secrets**: the
Authentik application is a PKCE client with no client secret.
| `deploy/.env` variable | Build arg | Production value |
| --- | --- | --- |
| `NESTQUEST_AUTHENTIK_CLIENT_ID` | `VITE_AUTHENTIK_CLIENT_ID` | client id of the Authentik NestQuest application (Authentik admin → Applications → Providers) |
| `NESTQUEST_AUTHENTIK_ISSUER` | `VITE_AUTHENTIK_ISSUER` | `https://auth.cubecraftlabs.com/application/o/nestquest/` |
| `NESTQUEST_API_BASE_URL` | `VITE_API_BASE_URL` | `https://nestquest.cubecraftlabs.com` |
The Authentik application's redirect URI must include
`https://nestquest.cubecraftlabs.com/auth/callback`.
**nginx** (`deploy/pwa/nginx.conf`):
- `allow 10.60.1.33; allow 10.60.1.14; deny all;` — only Traefik on
nukapi-03 and the API box itself get content; any other source gets
`403`, even with the port reachable on the LAN. Checks run on the API
box must use its LAN address (`10.60.1.14:8081`), not `127.0.0.1`.
- SPA fallback: unknown paths (e.g. `/auth/callback`) serve `index.html`;
`/assets/*` (content-hashed, cached for a year) returns a real `404`
when missing.
- `/sw.js` and `/manifest.webmanifest` (`application/manifest+json`) are
served from the site root with `Cache-Control: no-cache`, as is
`index.html`, so a new release's service worker is picked up.
Build and start (the `api` and `litestream` services are unaffected):
dc build pwa
dc up -d pwa
dc ps pwa # "Up"
**Traefik router** — in `config/dynamic/nestquest-ccl.yml` on nukapi-03
(reference shape in section 3):
| Router | Rule | Priority | Service |
| --- | --- | --- | --- |
| `nestquest-app` | ``Host(`nestquest.cubecraftlabs.com`) && !PathPrefix(`/api`) && !Path(`/health`) && !Path(`/openapi.json`) && !PathPrefix(`/docs`)`` | `50` — below `nestquest-admin` (100) and `nestquest-panel` (200) | `nestquest-pwa` → `http://10.60.1.14:8081` |
Same `websecure` entrypoint and `myresolver` certificate as the API
routers; no Traefik middleware (nginx enforces the source allow-list).
The file provider reloads the dynamic file on save.
**Verification** (in addition to section 9):
| # | Check | Command | Expect |
| --- | --- | --- | --- |
| 1 | PWA root over TLS | `curl -s -w '\n%{http_code}\n' https://nestquest.cubecraftlabs.com/ \| tail -3` | `200`, the `NestQuest Admin` shell |
| 2 | Client route fallback | `curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://nestquest.cubecraftlabs.com/auth/callback` | `200 text/html` |
| 3 | Manifest and worker | `curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://nestquest.cubecraftlabs.com/manifest.webmanifest` and the same for `/sw.js` | `200 application/manifest+json`, `200 application/javascript` |
| 4 | API still routed to the API | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health` | `200` (`{"status":"ok"}`, not HTML) |
| 5 | Panel refused externally | section 9 check 4 | `403` |
| 6 | Non-Traefik source refused | from a LAN machine other than `10.60.1.33`/`10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `403` (nginx `deny all`) |
| 7 | Proxy source allowed | on `10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `200` |
+19
View File
@@ -42,6 +42,25 @@ services:
retries: 5 retries: 5
start_period: 15s start_period: 15s
# Admin PWA (Feature 24): the Vite bundle from admin/ served by nginx
# (deploy/pwa/). Traefik routes every non-API path of the public host
# here on port 8081; nginx itself only accepts the proxy (10.60.1.33)
# and this host (10.60.1.14). The three build args are PUBLIC values
# (the OIDC client is a PKCE client, no secret) read from deploy/.env
# by compose interpolation; the image build fails if any is empty.
pwa:
build:
context: ..
dockerfile: deploy/pwa/Dockerfile
args:
VITE_AUTHENTIK_CLIENT_ID: ${NESTQUEST_AUTHENTIK_CLIENT_ID:-}
VITE_AUTHENTIK_ISSUER: ${NESTQUEST_AUTHENTIK_ISSUER:-}
VITE_API_BASE_URL: ${NESTQUEST_API_BASE_URL:-}
image: nestquest-pwa:latest
restart: unless-stopped
ports:
- "${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8081:80"
# Continuous backup: streams the SQLite database on the shared named # Continuous backup: streams the SQLite database on the shared named
# volume to the Synology NAS over SFTP (config: deploy/litestream.yml). # volume to the Synology NAS over SFTP (config: deploy/litestream.yml).
# The SFTP private key deploy/litestream_key is git-ignored and must be # The SFTP private key deploy/litestream_key is git-ignored and must be
+29
View File
@@ -0,0 +1,29 @@
# Admin PWA image (Feature 24): builds the Vite bundle from admin/ and
# serves it with nginx. Build context is the REPOSITORY ROOT (it needs
# admin/); deploy/pwa/Dockerfile.dockerignore limits that context to
# admin/ and this directory's nginx.conf.
#
# docker compose -f deploy/compose.yaml build pwa
#
# The VITE_* values are baked into the bundle at build time. None is a
# secret (the OIDC client is a public PKCE client), and all three are
# required: the build fails fast rather than relying on app defaults.
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS build
ARG VITE_AUTHENTIK_CLIENT_ID
ARG VITE_AUTHENTIK_ISSUER
ARG VITE_API_BASE_URL
WORKDIR /src/admin
COPY admin/package.json admin/package-lock.json ./
RUN npm ci
COPY admin/ ./
RUN test -n "$VITE_AUTHENTIK_CLIENT_ID" && test -n "$VITE_AUTHENTIK_ISSUER" && test -n "$VITE_API_BASE_URL" \
|| { echo "VITE_AUTHENTIK_CLIENT_ID, VITE_AUTHENTIK_ISSUER and VITE_API_BASE_URL build args are required" >&2; exit 1; }
RUN npm run build
FROM nginx:alpine@sha256:1ed1b0e1d7652937d6cbdaf4018c7b6fc009a7dd6c3047351e2eddda745de43f
COPY deploy/pwa/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/admin/dist/ /usr/share/nginx/html/
+11
View File
@@ -0,0 +1,11 @@
# Build-context filter for deploy/pwa/Dockerfile (BuildKit reads
# <Dockerfile>.dockerignore in place of the root .dockerignore, which
# excludes deploy/). Only admin/ sources and nginx.conf enter the
# build; local dependencies, build output and .env files never do.
*
!admin/
!deploy/pwa/nginx.conf
admin/node_modules
admin/dist
**/.env
**/.env.*
+53
View File
@@ -0,0 +1,53 @@
# nginx server for the NestQuest admin PWA (Feature 24). Replaces the
# image's /etc/nginx/conf.d/default.conf. The shared Traefik on
# nukapi-03 (10.60.1.33) terminates TLS for nestquest.cubecraftlabs.com
# and forwards every path that is not the API (/api, /health,
# /openapi.json, /docs) here; see deploy/RUNBOOK.md section 10.
server {
listen 80;
server_name _;
# Only the reverse proxy and the API box itself may connect; every
# other source is refused (403) even if the port is reachable.
allow 10.60.1.33;
allow 10.60.1.14;
deny all;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
# Content-hashed bundle files: cache forever, and a missing asset is
# a real 404 rather than the SPA shell.
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# The service worker must be revalidated on every load so a new
# release's worker (fresh revision, see admin/vite-sw-plugin.ts) is
# picked up.
location = /sw.js {
try_files $uri =404;
add_header Cache-Control "no-cache";
}
# nginx's stock mime.types has no .webmanifest entry.
location = /manifest.webmanifest {
types { }
default_type application/manifest+json;
try_files $uri =404;
add_header Cache-Control "no-cache";
}
# SPA fallback: client routes such as /auth/callback resolve to the
# app shell, which is never cached so it always names the current
# hashed assets.
location / {
try_files $uri $uri/ /index.html;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
}