Skip to content

Self-host on the Synology NAS + de-intensify compute (off Vercel)

Why

Vercel Hobby's Fluid Active-CPU quota (4 hrs/mo) was blown to 302%. Root cause (diagnosed by the cloud agent): per-request headless Chromium for invoice/WOPC thumbnails + a notification 30s-polling fallback + on-the-fly PDF rendering. Owner wants to move OFF Vercel onto the Synology NAS (free, owned hardware). A NAS can't take that compute either, so de-intensifying is mandatory either way β€” it also restores Vercel Hobby if we end up staying.

Scope (owner-approved 2026-06-20)

  • App host β†’ NAS (Docker + a free Cloudflare Tunnel for webhook ingress).
  • KEEP Firestore + Firebase Auth + Google Drive on Google β€” free at 2-user scale; moving them is a months-long re-platform of the app's core, not worth it.
  • Optionally move the 2 Cloud Run containers (PDF renderer, bank-login) β†’ NAS later (already ~free on Cloud Run scale-to-zero; bank-login drives OCBC via Chromium β€” relocation is the riskiest).
  • NAS ops (docker build/run, Tunnel auth on 100.101.13.71) are owner-run β€” the local agent sandbox can't reach the NAS; the agent does the in-repo artifacts + code.

De-intensify (#2 β€” required for the NAS), from the compute-burn diagnosis

  • #1 DONE (nightly) β€” invoice thumbnails no longer render per-request. The endpoint (pages/api/invoices/[year]/[projectId]/[invoiceNumber]/thumbnail.ts) now serves the STORED thumbnail first (302, 1-day cache) β†’ zero Chromium on the common path. Only invoices never rendered fall through to a one-time Chromium render that PERSISTS the result (uploadInvoiceThumbnail) so it's never rendered again. Dropped the ?ts=Date.now() cache-buster in InvoiceDetailsDrawer + Cache-Control 300sβ†’86400s. Chromium goes from once-per-VIEW β†’ at most once-per-invoice-ever.
  • #2 DONE (nightly) β€” WOPC thumbnails. Turns out it's React SSR β†’ HTML (NOT headless Chromium β€” correcting the diagnosis; the cost is modest). Bumped its cache 60s β†’ 600s so hovering a preview doesn't re-render the doc + re-read Firestore every time. [wopc-thumbnail.ts]
  • #3 β€” PDFs render on-the-fly. DEFERRED (scoped follow-up), with findings. Investigated the invoice PDF pipeline in full:
    • WOPC payment-confirmation PDFs use pdf-lib (renderWopcPdfBuffer) β€” cheap, NOT Chromium. Leave.
    • Chromium-heavy: invoice pdf.ts, coaching/.../pdf.ts, records/wopcs/pdf.ts.
    • Invoice download is a DOUBLE render: handleDownloadPdf opens pdf.ts (renders fresh) and fire-and-forget POSTs store-pdf (renders AGAIN to persist). So every download = 2 headless renders.
    • The stored invoice PDF is DEAD STORAGE β€” getInvoicePdfUrl/downloadInvoicePdf/invoicePdfExists have zero consumers; nothing ever serves it. store-pdf only renders to (a) write Firestore pdfFileId/pdfHash (flips ProjectShowApp's Exportβ†’View button β€” canViewPdf) and (b) persist a thumbnail, which post-#1 now self-populates via thumbnail.ts lazy-backfill anyway.
    • The freshness machinery is dead: currentHash in ProjectShowApp is hardcoded null, and invoices have no reliable updatedAt. So there is NO signal today to safely serve a stored PDF without risking a stale financial document. Why deferred, not a quick tweak: a correct fix is a pipeline refactor β€” serve pdf.ts stored-first behind a freshness guard (needs a new invoice-version/updatedAt signal) and collapse the double render β€” with financial-correctness stakes (never serve a stale invoice) and download-UX trade-offs (store-then-serve would delay the open by 10-30s). And the payoff is ~nil on the NAS, where Chromium runs on owned CPU (not metered) and is already bounded by tryAcquireRenderSlot + the remote-render offload. Recommend a dedicated task post-NAS: add invoice.pdfVersion, persist it into PDF object metadata at render time, serve stored-first when versions match, and drop the redundant store-pdf render. The actual Vercel quota drivers were #1 (per-row, constant) + #4 (sustained) β€” both fixed.
  • #4 β€” Notification polling fallback. Backstop DONE (nightly): POLL_FALLBACK_INTERVAL 30s β†’ 2 min so a failed listener can't hammer /api/notifications every 30s per tab. ROOT FIX (owner deploy): the needed index already EXISTS in firestore.aote-system.indexes.json (notifications: recipientUid+read+createdAt) β€” it just isn't deployed β†’ firebase deploy --only firestore:indexes. Once live the listener works and polling never starts.
  • #5 β€” list-row thumbnails. RESOLVED BY #1 (no separate change needed). The concern was per-row <img> mounts triggering Chromium. After #1, every list-row thumbnail hits the stored-first endpoint (a 302 to a cached signed URL, or a one-time render that then persists) β€” so mounting <img> in lists is now cheap and never re-renders. A placeholder-in-list refinement would only save a redirect fetch; marginal and not worth the UX cost of blank list rows. Closing as covered by #1.

NAS deployment artifacts (in-repo) β€” DONE (nightly)

  • Dockerfile (root) β€” multi-stage: next build β†’ distro Chromium + CJK fonts β†’ next start. Deliberately NOT output: 'standalone' β€” the app reads disk assets at runtime via process.cwd() (IR56M IRD template, classic invoice scheme, prompts/, public/ fonts) and loads native/wasm deps by dynamic import (@sparticuz/chromium, mupdf); standalone tracing has missed exactly these (the outputFileTracingExcludes outage). Full build + node_modules instead β€” disk is free on the NAS.
  • Chromium: distro chromium + PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium (getExecutablePath honors it first β†’ @sparticuz/chromium only supplies launch args, binary never inflated). fonts-noto-cjk so Traditional-Chinese invoices/WOPC/IR56M render real glyphs.
  • docker-compose.yml β€” app + the existing services/bank-login-service sidecar (reached via bank-login:8080), shm_size: 1gb for both (Chromium), optional cloudflared service.
  • .dockerignore, .env.production.example (build-time NEXT_PUBLIC_* as build args + runtime deltas; points to .env.example for the full server list), .gitignore updated to ignore .env.production /.env.bank-login/.env.cloudflared.
  • docs/deploy/nas-runbook.md β€” full owner runbook (build/run, Cloudflare Tunnel, build-elsewhere for RAM-constrained NAS, verification of the Chromium/wasm paths, caveats). Includes a Connecting to the NAS section: Cloudflare Tunnel (https://nas.theestablishers.com/, tunnel roofkeeper-nas β†’ DSM on localhost:5000) is the preferred path β€” works from anywhere incl. a Claude Code Web sandbox; the agent drives the DSM Web API (FileStation + a Task-Scheduler root-exec dispatcher). SSH gatekeeper@100.101.13.71 over Tailscale is a local-only fallback (Tailscale is rejected by the Web-sandbox egress). The authoritative channel doc is docs/eop-tasks/runbooks/claude-nas-channel.md (written by the cloud agent during T-053a; currently on branch claude/t053a-statement-parser-fix, not yet merged to nightly so not on the Cloudflare task page). Earlier I'd speculated SSH-over- Cloudflare + an owner-fill hostname placeholder β€” both now corrected against that doc. Also updated the "Public ingress" section to add eop.theestablishers.com as a second hostname on the existing roofkeeper-nas tunnel rather than a second cloudflared.
  • Build needs ~8GB heap (baked: NODE_OPTIONS=--max-old-space-size=8192); leave PDF_RENDER_* unset so PDFs render locally. NOT validated in-sandbox (no Docker / no NAS reach) β€” owner builds on the NAS.

DEPLOYED β€” app LIVE on the NAS (2026-06-20)

Done entirely over the Cloudflare DSM Web API channel (no SSH). See [[project_eop_nas_deploy]] for the full mechanics. - Build: GitHub Actions .github/workflows/nas-image.yml (tag-triggered nas-build-*) builds the linux/amd64 image β†’ ghcr.io/girafeev1/eop-app:latest. Build fixes that were needed: node:22-slim + npm install -g npm@11 before npm ci (lockfile is npm 11; the base shipped npm 10 β†’ "Missing @esbuild/win32- from lock file"). Image β‰ˆ 4.8GB. - Pull + run on NAS: docker login ghcr.io with a read:packages PAT, docker pull, then docker run -d --name eop-app --env-file /volume1/docker/eop/.env.production -p 3000:3000 --shm-size=1g. .env.production assembled from .env.local (quotes stripped for docker --env-file, non-identifier keys dropped, channel creds + PDF_RENDER_* excluded, NAS overrides applied). - Verified: container stable (0 restarts), serves localhost:3000 β†’ /auth/signin; NextAuth + Firebase Admin working. - Public route: roofkeeper-nas tunnel has a published-application route eop β†’ localhost:3000. The DNS cutover off Vercel is held β†’ tracked as T-074 (eop.theestablishers.com still points to the disabled Vercel deployment until then). - bank-login (OCBC) sidecar β€” DEPLOYED 2026-06-20. Own workflow bank-login-image.yml (tag banklogin-build-*) β†’ ghcr.io/girafeev1/eop-bank-login (β‰ˆ1.68GB). Runs on a user-defined docker network eop-net (the app docker network connect'd onto it, no restart) with FIRESTORE_DATABASE_ID=aote-system + the nas-billing-scraper SA key. Verified: healthy, chromium pre-warmed, appβ†’bank-login:8080/health = 200. Restores OCBC login on the NAS (relates to T-028). RAM note: both containers leave ~555MB free on the 1.9GB box β€” tight but OK. - wopc/sign pdfjs DOMMatrix SSR error β€” FIXED 2026-06-20. Root cause: the wopc/sign Client Components statically import … from 'react-pdf', and Next SSRs Client Components, so Node evaluated pdfjs-dist's module-level browser-global refs. Fix = instrumentation.ts register() stubs the canvas globals (DOMMatrix/Path2D/ImageData/DOM{Point,Rect}[ReadOnly]) on the nodejs runtime only; real rendering still runs client-side. Verified after redeploy: forced /wopc/sign/* SSR β†’ 0 DOMMatrix occurrences* in logs, clean βœ“ Ready, app serving + sidecar reachable.

βœ… Read AGENTS.md Β· Codex local session Β· checked the board by scope; T-073 already covers the WOPC signing PDF.js crash lineage, so no duplicate task was opened Β· tracking T-073.

Source: Codex local session Β· local Codex desktop thread

Owner report (verbatim, 2026-06-27):

"Can you help me tackle with why the link in the email sent to the closing director for signing and sealing the WOPC do not work anymore (like this link here: https://eop.theestablishers.com/wopc/sign/79DkCWLu6btYB4S41nLX)"

The owner then shared Jake's phone screenshot showing the app-level "Something went wrong" error boundary on deployed build 9e8281a. The owner later clarified Jake used Chrome.

Diagnosis. The email URL and request data were not the failing layer. Public unauthenticated probing returns a proper 307 to /api/auth/signin?callbackUrl=%2Fwopc%2Fsign%2F79DkCWLu6btYB4S41nLX, so a not-logged-in user should be sent to login, not to the app error page. Request 79DkCWLu6btYB4S41nLX exists in aote-system/wopcSigningRequests for ERL-WOPC/2025-021, assigned to Jake Ngai (jake@establishrecords.com), status sent. The full-page error boundary therefore points to a client runtime exception after the route loads. The WOPC signing client imports react-pdf, which brings in pdfjs-dist@5.4.296; the normal PDF.js build calls Promise.withResolvers(), and React-PDF's own README notes older browser support requires polyfills for APIs including Promise.withResolvers. On iPhone, Chrome still runs on Apple's WebKit browser engine, so Chrome can hit the same missing-WebKit-API path. When that API is absent, the signing route can throw before the page's own PDF preview error state has a chance to render.

What changed. Added lib/pdfjsClientCompat.ts, imported it from app/providers.tsx, and routed all WOPC/IR56M signing + upload/preview PDF.js worker setup through configurePdfJsWorker(). The helper installs a tiny Promise.withResolvers polyfill when missing and points PDF.js workers at pdfjs-dist's legacy/build/pdf.worker.min.mjs, because the page-level polyfill does not cross into the worker context.

Verification. NODE_OPTIONS=--max-old-space-size=4096 npx tsc --noEmit passes. The pinned legacy worker URL for pdfjs-dist@5.4.296 returns HTTP 200 from unpkg. Live phone verification is still required after deploy because the original failure depends on Jake's browser runtime.

Blast radius. Client-side PDF preview/render only: WOPC signing pages, IR56M signing pages, WOPC bulk/upload-on-behalf PDF extraction, receipt PDF first-page conversion, and IR56M form preview. Server-side PDF creation and WOPC signing request state are unchanged.

βœ… Read AGENTS.md Β· Codex local session Β· checked the board by scope; T-073 already covers the WOPC signing-link failure, so no duplicate task was opened Β· tracking T-073.

Source: Codex local session Β· local Codex desktop thread

Owner follow-up (verbatim, 2026-06-28):

"Okay, the sign WOPC link still doesn't work and Chrome log shows this: layout-463545e4bf59e41e.js:1 [firebase] Initializing Firebase app Object 6348-e3eb7a9905920777.js:7 Error: An error occurred in the Server Components render..."

Correction. The PDF.js compatibility fix landed in the running image, but it was not the whole failure. Reaching the NAS over Tailscale (Claude@100.101.13.71) showed the live container had been recreated from ghcr.io/girafeev1/eop-app:main, NEXTAUTH_URL=https://eop.theestablishers.com, RBAC_ENABLED=true, USERS_DATABASE_ID=aote-system, and WOPC_ASSETS_BUCKET=aote-system-assets. The image also contains lib/pdfjsClientCompat.ts. The remaining issue was authorization shape: WOPC signing is a document-specific director action, but the route required broad subsidiary access before it checked whether the logged-in email was the assigned director. A director with no normal users/{uid} RBAC profile or no ERL subsidiary grant could authenticate poorly or be blocked before the signing UI rendered.

What changed.

  • pages/api/auth/[...nextauth].ts now allows a known director email (lib/directors/registry.ts) to hold a signing-only session when no user profile/invitation exists. The session gets no subsidiary access and no permissions (role=pending, status=active, profileMissing=true), so it does not become a normal app account.
  • app/wopc/sign/[requestId]/access.server.ts centralizes the WOPC signing-page guard. The root page is visible to subsidiary-authorized users, admins, or the assigned director. Actual signing steps (method, draw, editor, chop, preview, upload) require the assigned director or an admin.
  • lib/wopc/signingRequests/apiAuth.ts gained an explicit allowAssignedDirector option. It is enabled only for signer-private endpoints (sign, reject, signature, preview-signed, chop-image). Requester/admin operations (create, assign, send, withdraw, lookup/listing) still require the normal subsidiary access path.

Verification. NODE_OPTIONS=--max-old-space-size=4096 npx tsc --noEmit passes. Public unauthenticated access still redirects to login; post-login verification must be done with Jake's Google account because the corrected path depends on the director-login email matching jake@establishrecords.com.

Blast radius. Authentication now has a narrow director-signing-only session path. It grants no app permissions and no subsidiary access; it only lets the WOPC signing routes/APIs authorize against the assigned-director email. Normal RBAC and WOPC requester actions remain fail-closed.

STATUS: DONE (2026-06-20)

Self-hosting on the NAS is complete and live: de-intensify shipped, app image built via CI→GHCR and running on the NAS behind the Cloudflare tunnel (eop.theestablishers.com), OCBC sidecar deployed, and the DOMMatrix SSR crash fixed. Remaining odds-and-ends are tracked elsewhere: Vercel teardown = T-074 (DNS already cut over). Minor non-blocking optimizations noted for later: the Docker runner bundles the whole app in one ~4.8GB layer so every redeploy re-pulls it all (split node_modules vs build for delta pulls); and RAM is tight (~555MB free with both containers on the 1.9GB box).

No overlap

New workstream β€” no existing task covers compute-perf or hosting. The thumbnail fix reuses the existing stored-thumbnail storage layer (uploadInvoiceThumbnail / getInvoiceThumbnailUrl).