Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
30 KiB
NestQuest API — deployment and restore runbook
Operator procedure to rebuild the production NestQuest API deployment end to end from this repository, restore its database from backup, and verify it. Every value below is the real, non-secret production value. No secret appears in this file — secrets are named and their location is given, never their content.
| Piece | Where | Real value |
|---|---|---|
| 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 |
| Public hostname | Cloudflare DNS | nestquest.cubecraftlabs.com |
| Identity | Authentik | https://auth.cubecraftlabs.com |
| Home Assistant box (panel client) | LAN | 10.60.1.80 |
| Local DNS | Pi-hole | 10.60.1.101 |
| Backup target | Synology NAS CubeCraft-SRVR1, SFTP |
10.60.1.50:/homes/Joshua/nestquest-backups |
All docker compose commands below are run on the API box, from the
repository root. The shorthand dc means:
alias dc='docker compose -f deploy/compose.yaml'
Compose v2.24 or newer is required (env_file uses the path/required
form).
1. Image build
The image is defined by the root Dockerfile and is built from the
repository root as the build context: the API (api/) loads the
bundled domain core from custom_components/nestquest/core/ by file
location, so both trees must be in the context. .dockerignore keeps
everything else (including deploy/, .env files and *.db) out of the
build.
- Multi-stage on
python:3.12-slim: the builder installsapi/requirements.txt, the runtime copies only the installed packages plusapi/andcustom_components/nestquest/core/into/app. - Runs as the non-root system user
nestquest(uid/gid999), which owns/var/lib/nestquest. - Command:
uvicorn --factory api.app:create_app --host 0.0.0.0 --port 8080.
Get the code and build:
git clone ssh://code.cubecraftlabs.com:2288/Maniacs_Mansion/NestQuest.git
cd NestQuest
git checkout <release branch or tag being deployed>
dc build api # equivalent: docker build -t nestquest-api:latest .
Confirm the runtime user (the Litestream sidecar and the key ownership in section 5 depend on uid 999):
docker run --rm --entrypoint id nestquest-api:latest
# uid=999(nestquest) gid=999(nestquest) groups=999(nestquest)
2. Compose
deploy/compose.yaml defines three services and one named volume:
| Service | Image | Role |
|---|---|---|
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 |
pwa |
nestquest-pwa:latest (built from deploy/pwa, section 10) |
Admin PWA served by nginx on 8081; independent of the other two |
- Volume
nestquest-dbis mounted at/var/lib/nestquestin both services. The database isNESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db(fixed by compose, not a secret).docker compose downkeeps named volumes; never rundown -von the API box. Docker prefixes the volume with the compose project name — with the default project (deploy, the compose file's directory) it isdeploy_nestquest-db; confirm withdocker volume ls | grep nestquest-db. - Published port:
${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8080:8080. The default binds localhost only, so an unconfigured host is never exposed. On the API boxdeploy/.envsetsNESTQUEST_BIND_ADDRESS=10.60.1.14so Traefik on nukapi-03 can reachhttp://10.60.1.14:8080. - Secrets come from
deploy/.env(git-ignored, section 5) viaenv_file.
Bring the stack up (after section 5's secrets exist):
dc up -d --build
dc ps # api "healthy", litestream "Up"
curl -s http://10.60.1.14:8080/health # {"status":"ok"}
3. TLS and reverse proxy (Traefik on nukapi-03)
TLS is terminated by the shared Traefik instance on nukapi-03
(10.60.1.33), not by this repository. Its NestQuest routing is the
owner-authored dynamic file-provider config
config/dynamic/nestquest-ccl.yml in the Traefik deployment on
nukapi-03 — that live file is authoritative; it is not stored in this
repository. What it must contain:
| Router | Rule | Middleware | Service |
|---|---|---|---|
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 |
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 |
All routers use the HTTPS entrypoint websecure with tls.certResolver: myresolver
(HTTP is redirected to HTTPS by the shared Traefik). Priorities are set
explicitly so the order never depends on rule length: panel (200) above
admin (100), and the PWA's nestquest-app router (50) below both. The
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 must match the shared Traefik's static config on nukapi-03):
http:
routers:
nestquest-panel:
rule: "Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)"
priority: 200
entryPoints: [websecure]
middlewares: [nestquest-panel-lan]
service: nestquest-api
tls:
certResolver: myresolver
nestquest-admin:
rule: "Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api/v1/admin`) || Path(`/health`) || Path(`/openapi.json`) || PathPrefix(`/docs`))"
priority: 100
entryPoints: [websecure]
middlewares: [nestquest-headers]
service: nestquest-api
tls:
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:
nestquest-panel-lan:
ipAllowList:
sourceRange: ["10.60.1.80/32"]
nestquest-headers:
# headers middleware; copy its definition from the live file
services:
nestquest-api:
loadBalancer:
servers:
- 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
Encrypt) resolver using the Cloudflare DNS-01 challenge; the
Cloudflare API token is held by the Traefik deployment on
nukapi-03, never in this repository. Because DNS-01 proves control
through a DNS TXT record, issuance and renewal need no inbound HTTP and
work regardless of the Cloudflare proxy setting. Certificates renew
automatically.
Cloudflare proxy note. Public traffic for
nestquest.cubecraftlabs.com resolves through Cloudflare, so a request
from the internet reaches Traefik from a Cloudflare edge address, never
from 10.60.1.80 — which is exactly why the panel allowlist refuses it
(403). The HA box does not go through Cloudflare at all: the local DNS
override in section 6 sends it straight to Traefik on the LAN, so Traefik
sees its real address. Do not add Cloudflare ranges to the panel
allowlist or make the allowlist trust X-Forwarded-For; that would let
any internet client through to the panel plane.
4. Authentik (admin identity)
Admin-plane requests (/api/v1/admin/*) carry an Authentik OIDC access
token (JWT). The API verifies issuer, audience, RS256 signature against
the JWKS, and expiry, then requires the groups claim to contain
nestquest-admins (api/auth.py). Configure in Authentik
(https://auth.cubecraftlabs.com, admin interface):
- Provider — OAuth2/OpenID Provider:
- Client type: Public (no client secret; the admin PWA is a browser SPA).
- PKCE: the PWA always uses the authorization-code flow with PKCE,
code_challenge_method=S256, and never sends a client secret. - Redirect URI (strict):
https://nestquest.cubecraftlabs.com/auth/callback. - Signing key: an RSA certificate (the API only accepts
RS256). - Scopes: the default
openid,email,profilemappings (the PWA requestsopenid email profile; Authentik's defaultprofilemapping emits thegroupsclaim the API checks). - Note the generated Client ID — it is the audience below and the
PWA's
VITE_AUTHENTIK_CLIENT_ID.
- Application — slug
nestquest, bound to the provider above. The slug fixes the issuer URL. - Group —
nestquest-admins. Add each parent's Authentik user. Membership is the single source of truth for "who is an admin"; the API refuses a signed-in non-member with 403.
API settings (in deploy/.env, section 5):
| Variable | Value |
|---|---|
NESTQUEST_OIDC_ISSUER |
https://auth.cubecraftlabs.com/application/o/nestquest/ |
NESTQUEST_OIDC_AUDIENCE |
the provider's Client ID |
NESTQUEST_OIDC_JWKS_URL |
https://auth.cubecraftlabs.com/application/o/nestquest/jwks/ |
Check them against Authentik's discovery document (the issuer must match byte for byte, including the trailing slash):
curl -s https://auth.cubecraftlabs.com/application/o/nestquest/.well-known/openid-configuration \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["issuer"]); print(d["jwks_uri"])'
5. Secrets
Nothing below is ever committed; .gitignore excludes .env,
**/.env, deploy/*key* and deploy/litestream_key*.
deploy/.env — created on the API box from the committed example:
cp deploy/.env.example deploy/.env
chmod 600 deploy/.env
# edit deploy/.env and fill in:
# NESTQUEST_PANEL_TOKEN = a new random token, e.g.
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# (the same value is configured in the HA integration)
# NESTQUEST_OIDC_ISSUER = section 4
# NESTQUEST_OIDC_AUDIENCE = section 4 (Authentik Client ID)
# NESTQUEST_OIDC_JWKS_URL = section 4
# NESTQUEST_BIND_ADDRESS = 10.60.1.14
The API fails fast at startup if any of the four API
variables is unset or empty (api/config.py); dc logs api names the
missing one. NESTQUEST_BIND_ADDRESS is read by compose only.
Litestream SFTP key deploy/litestream_key — a dedicated keypair that
lives only on the API box. Create it once, before the first up:
ssh-keygen -t ed25519 -N "" -C nestquest-litestream -f deploy/litestream_key
sudo chown 999:999 deploy/litestream_key
chmod 600 deploy/litestream_key
Then append restrict followed by the contents of
deploy/litestream_key.pub as one line to
joshua@10.60.1.50:~/.ssh/authorized_keys on the NAS. Compose mounts the
private key read-only at /etc/litestream/sftp_key in the sidecar, which
runs as uid 999 — hence the ownership. The NAS host key is pinned in the
committed deploy/litestream.yml (host-key, the NAS's ECDSA key), so no
known_hosts is involved.
| Secret | Supplied by | Consumed by |
|---|---|---|
NESTQUEST_PANEL_TOKEN |
deploy/.env → env_file |
api (panel plane); also set in the HA integration |
NESTQUEST_OIDC_* (not secret, but environment-supplied) |
deploy/.env → env_file |
api (admin plane) |
| SFTP private key | deploy/litestream_key → read-only bind mount |
litestream |
| Cloudflare DNS API token | Traefik deployment on nukapi-03 | Traefik ACME myresolver |
6. Network separation and firewall
-
Panel plane (
/api/v1/panel/*, static service token): reachable only from the HA box. Traefik'sipAllowListon the panel router allows10.60.1.80/32and returns 403 to every other source, before the request reaches the API. The API additionally requires the panel token (401 without it). -
Admin plane (
/api/v1/admin/*) and/health,/docs: public through Cloudflare and Traefik; admin routes are protected by the API's Authentik JWT check (401 without a valid token, 403 withoutnestquest-adminsor when a panel token is presented). -
Local DNS override so the HA box reaches Traefik directly, keeping its real source address: in Pi-hole (
10.60.1.101) local DNS records, addnestquest.cubecraftlabs.com→10.60.1.33. The HA box must use Pi-hole as its resolver. Check from the HA box:nslookup nestquest.cubecraftlabs.com 10.60.1.101 # Address: 10.60.1.33 -
Direct
:8080access is firewalled. The API box publishes8080on10.60.1.14, which alone would let any LAN host bypass Traefik and the panelipAllowList. This is enforced on the API box in Docker'sDOCKER-USERchain (which Docker evaluates before its own forwarding rules for published ports), in this order:-A DOCKER-USER -s 10.60.1.33/32 -p tcp --dport 8080 -j RETURN # Traefik on nukapi-03 -A DOCKER-USER -s 10.60.1.14/32 -p tcp --dport 8080 -j RETURN # the API box itself -A DOCKER-USER -p tcp --dport 8080 -j DROP # every other sourceThe rules are persisted by the systemd oneshot unit
nestquest-api-firewall.service, committed asdeploy/systemd/nestquest-api-firewall.service(ordered afterdocker.service, it idempotently applies the three rules at every boot). Install and enable it on the API box, from the repository checkout, before exposing the API:sudo cp deploy/systemd/nestquest-api-firewall.service /etc/systemd/system/ \ && sudo systemctl daemon-reload \ && sudo systemctl enable --now nestquest-api-firewall.serviceThe HA box and every other client must use the Traefik hostname, never
:8080directly. Check it:sudo systemctl is-enabled nestquest-api-firewall.service # enabled sudo systemctl is-active nestquest-api-firewall.service # active sudo iptables -S DOCKER-USER # the three rules above, in that order # on nukapi-03 (10.60.1.33) — allowed: curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8080/health # 200 # on any other LAN host, e.g. 10.60.1.101 — dropped: curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8080/health # 000, timeout # public path still works: curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health # 200On a rebuilt API box, install the unit with the commands above before exposing the API. The committed file is the authoritative copy; the deployed unit must match it:
diff deploy/systemd/nestquest-api-firewall.service \ /etc/systemd/system/nestquest-api-firewall.service # no output
7. Backup (Litestream to the Synology NAS)
The litestream sidecar (config deploy/litestream.yml, mounted at
/etc/litestream.yml) replicates /var/lib/nestquest/nestquest.db to:
- SFTP
joshua@10.60.1.50, path/homes/Joshua/nestquest-backups(the NAS's SFTP view is chrooted to the shared folders; over SSH the same directory is/volume1/homes/Joshua/nestquest-backups). - Every committed change is shipped as an LTX file within about a second. Litestream 0.5.17 compacts them into coarser levels on its built-in schedule (level 1 every 30s, level 2 every 5m, level 3 every 1h).
- Snapshots:
snapshot.interval: 24h(a full snapshot daily); retention:snapshot.retention: 168h— a week of snapshots plus the changes between them, i.e. a 7-day point-in-time restore window.
The sidecar runs as uid 999 with a read-only root filesystem,
no-new-privileges and all capabilities dropped. It needs the database
volume read-write (it keeps its _litestream_* tables and writes the
WAL); its local state lives beside the database in
/var/lib/nestquest/.nestquest.db-litestream/.
Check it:
dc ps litestream # Up
dc logs --tail 50 litestream # no "error" lines
dc exec litestream litestream status # status "ok", local txid advancing
dc exec litestream litestream ltx /var/lib/nestquest/nestquest.db | tail # newest LTX files and timestamps
A change made in the app should appear as a new level-0 LTX file within
seconds. On the NAS, the files are under
/volume1/homes/Joshua/nestquest-backups/ltx/.
8. Restore
Restores the database from the SFTP replica into the nestquest-db
volume at NESTQUEST_DB_PATH (/var/lib/nestquest/nestquest.db). The
same procedure serves both a damaged database on the existing box and a
rebuilt box. The restore runs in one-off litestream containers so it
has the same user (999), volume, config and SFTP key as the sidecar.
0. Fresh box only. Complete sections 1 and 5, then start only the API once so the volume exists and is owned by uid 999:
dc up -d api
Do not start litestream yet — it would begin replicating the new,
empty database the API just created. The swap in step 4 moves that empty
file aside, and step 5 creates and starts the sidecar with
dc up -d litestream once the API is healthy.
1. Stop writers — both the API and the sidecar (Litestream must not replicate while the file is being replaced):
dc stop api litestream
2. Restore beside the live database (never over it), with a full integrity check:
dc run --rm --no-deps litestream restore \
-config /etc/litestream.yml \
-integrity-check full \
-o /var/lib/nestquest/nestquest.db.restored \
/var/lib/nestquest/nestquest.db
Expect post-restore integrity check passed and exit code 0. For a
point in time within the retention window, add
-timestamp 2026-09-24T12:00:00Z (UTC). To preview without writing, add
-dry-run.
3. Inspect the restored file before switching to it — confirm known children and definitions are present:
dc run --rm --no-deps --entrypoint sqlite3 litestream \
/var/lib/nestquest/nestquest.db.restored \
'PRAGMA integrity_check;' \
'SELECT id, display_name FROM children;' \
'SELECT id, title FROM quest_definitions;'
Expect ok, then the household's children and quest definitions.
4. Swap it into place, keeping the old files (database, its
-wal/-shm, and Litestream's local state) in a timestamped directory
on the volume:
dc run --rm --no-deps --entrypoint sh litestream -c '
set -e
cd /var/lib/nestquest
ts=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p "pre-restore-$ts"
for f in nestquest.db nestquest.db-wal nestquest.db-shm .nestquest.db-litestream; do
if [ -e "$f" ]; then mv "$f" "pre-restore-$ts/"; fi
done
mv nestquest.db.restored nestquest.db
ls -la'
5. Start the API, then the sidecar:
dc start api
dc ps api # wait for "healthy"
dc up -d litestream
dc ps litestream # Up
dc logs --tail 20 litestream
Use dc up -d litestream, not dc start: on a fresh box step 0 never
created the sidecar container, and start cannot start a container that
does not exist. up -d creates it if needed (or starts the existing one),
so continuous replication is back online on both paths.
The sidecar resumes replication from the restored position (it logs
detected database behind replica / fetched latest L0 file from replica, then continues normally).
6. Verify through the API — the panel snapshot lists the restored
children. On the API box against 10.60.1.14:8080 directly (other LAN
hosts, including the HA box, are dropped on :8080 by the section 6
firewall; from the HA box use the Traefik hostname instead):
PANEL_TOKEN=$(grep '^NESTQUEST_PANEL_TOKEN=' deploy/.env | cut -d= -f2-)
curl -s -H "Authorization: Bearer $PANEL_TOKEN" \
http://10.60.1.14:8080/api/v1/panel/snapshot
Expect HTTP 200 and children naming the known child(ren). Then run
section 9.
Once satisfied, remove the old copy:
dc run --rm --no-deps --entrypoint rm litestream -rf /var/lib/nestquest/pre-restore-<ts>.
This exact sequence (stop, restore with integrity check, inspect, swap, start, snapshot shows the restored child, replication resumes) was rehearsed against a local file replica with the committed compose file; the live proof against the SFTP replica is recorded separately by the controller.
9. Verification checklist
Run after every deploy and every restore. $PANEL_TOKEN is the value from
deploy/.env; never paste it into shared logs.
| # | Check | Command | Expect |
|---|---|---|---|
| 1 | Health over TLS | curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health |
200 |
| 2 | Valid certificate | curl -sv https://nestquest.cubecraftlabs.com/health 2>&1 | grep -E 'issuer|expire date' |
Let's Encrypt issuer, future expiry (no -k needed) |
| 3 | Docs public | curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/docs |
200 |
| 4 | Panel refused externally | from any host other than 10.60.1.80 (e.g. a phone off Wi-Fi, or a LAN machine): curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $PANEL_TOKEN" https://nestquest.cubecraftlabs.com/api/v1/panel/snapshot |
403 (Traefik allowlist) |
| 5 | Panel allowed from HA box | on 10.60.1.80: same command |
200, snapshot JSON |
| 6 | Panel needs the token | on 10.60.1.80, without the header |
401 |
| 7 | Admin JWT accepted | curl -s -w '\n%{http_code}\n' -H "Authorization: Bearer $ADMIN_JWT" https://nestquest.cubecraftlabs.com/api/v1/admin/ping with a token of a nestquest-admins member |
200, {"status":"ok","sub":...} |
| 8 | Non-admin refused | same, with a token of a user not in nestquest-admins |
403, "Admin access requires membership in the nestquest-admins group" |
| 9 | No/invalid token refused | same, without a header or with garbage | 401 |
| 10 | Panel token refused on admin | same, with $PANEL_TOKEN |
403 |
| 11 | Backup running | dc exec litestream litestream status |
status ok |
To obtain $ADMIN_JWT: sign in to the admin PWA as the user, open the
browser developer tools, and copy the session-storage value
nestquest.access_token. Tokens are short-lived and are credentials —
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, thennpm run buildwith the threeVITE_*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:alpinepinned tosha256:1ed1b0e1d7652937d6cbdaf4018c7b6fc009a7dd6c3047351e2eddda745de43f(nginx 1.31.6): the builtdist/in/usr/share/nginx/htmlanddeploy/pwa/nginx.confasconf.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 gets403, even with the port reachable on the LAN. Checks run on the API box must use its LAN address (10.60.1.14:8081), not127.0.0.1.- SPA fallback: unknown paths (e.g.
/auth/callback) serveindex.html;/assets/*(content-hashed, cached for a year) returns a real404when missing. /sw.jsand/manifest.webmanifest(application/manifest+json) are served from the site root withCache-Control: no-cache, as isindex.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 |
11. Upgrades and rollback
The API applies pending schema migrations when it starts (FastAPI
lifespan, before /health answers), so the first start of a new
version is the point of no return for any migration it carries. A
build refuses a database whose schema version is newer than it
understands ("newer than this integration understands … downgrades are
not supported") and does not start.
Migration 9 (multiple presence patterns)
Migration 9 creates presence_patterns, copies every row of the old
one-per-child presence_schedules table into it as a home pattern
named Home schedule, and drops presence_schedules. Version 0.7.1
refuses the resulting version-9 database, and could not read
presence_patterns anyway, so the only way back to 0.7.1 is a copy of
the database taken before the first start on the new version.
Before the first start on the new version, with the old version still deployed:
-
Stop writers (as section 8 step 1):
dc stop api litestream -
Copy the database file beside the live one with SQLite's online backup (includes anything still in the WAL), then check it:
ts=$(date -u +%Y%m%dT%H%M%SZ) dc run --rm --no-deps --entrypoint sqlite3 litestream \ /var/lib/nestquest/nestquest.db \ ".backup /var/lib/nestquest/pre-v9-$ts.db" dc run --rm --no-deps --entrypoint sqlite3 litestream \ /var/lib/nestquest/pre-v9-$ts.db \ 'PRAGMA integrity_check;' \ 'SELECT version FROM nestquest_schema_version;' \ 'SELECT count(*) FROM presence_schedules;'Expect
ok, schema version8, and the number of presence schedules. Write down$ts. -
Also record the Litestream replica position and the UTC time, so a point-in-time restore (section 8,
-timestamp) can reach the pre-upgrade state even if the local copy is lost:dc up -d litestream dc exec litestream litestream status # note the txid date -u +%Y-%m-%dT%H:%M:%SZ # note the time dc stop litestreamThe replica only covers the 7-day retention window (section 7).
-
Deploy the new version (sections 1–2) and run section 9. Confirm the live database is at version
9(the section 8 step 3sqlite3command againstnestquest.db, with'SELECT version FROM nestquest_schema_version;') and that the Schedule tab lists each child's old schedule as aHome schedulepattern.
Rollback to 0.7.1: check out and build 0.7.1 (section 1), then put
the pre-upgrade database back with section 8: either steps 1 and 4–6
with pre-v9-<ts>.db in place of nestquest.db.restored, or the full
restore with -timestamp set to the time noted in step 3. Presence
pattern changes made after the upgrade are not in that copy and are
lost. Once the new version has proven itself, remove the copy:
dc run --rm --no-deps --entrypoint rm litestream -f /var/lib/nestquest/pre-v9-<ts>.db.
This rollback has not yet been rehearsed.