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 inInvoiceDetailsDrawer+ 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:
handleDownloadPdfopenspdf.ts(renders fresh) and fire-and-forget POSTsstore-pdf(renders AGAIN to persist). So every download = 2 headless renders. - The stored invoice PDF is DEAD STORAGE β
getInvoicePdfUrl/downloadInvoicePdf/invoicePdfExistshave zero consumers; nothing ever serves it.store-pdfonly renders to (a) write FirestorepdfFileId/pdfHash(flips ProjectShowApp's ExportβView button βcanViewPdf) and (b) persist a thumbnail, which post-#1 now self-populates viathumbnail.tslazy-backfill anyway. - The freshness machinery is dead:
currentHashin ProjectShowApp is hardcodednull, and invoices have no reliableupdatedAt. 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 β servepdf.tsstored-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 bytryAcquireRenderSlot+ the remote-render offload. Recommend a dedicated task post-NAS: addinvoice.pdfVersion, persist it into PDF object metadata at render time, serve stored-first when versions match, and drop the redundantstore-pdfrender. The actual Vercel quota drivers were #1 (per-row, constant) + #4 (sustained) β both fixed.
- WOPC payment-confirmation PDFs use pdf-lib (
- #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 NOToutput: 'standalone'β the app reads disk assets at runtime viaprocess.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-cjkso Traditional-Chinese invoices/WOPC/IR56M render real glyphs. docker-compose.ymlβ app + the existingservices/bank-login-servicesidecar (reached viabank-login:8080),shm_size: 1gbfor both (Chromium), optionalcloudflaredservice..dockerignore,.env.production.example(build-time NEXT_PUBLIC_* as build args + runtime deltas; points to.env.examplefor the full server list),.gitignoreupdated 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/, tunnelroofkeeper-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). SSHgatekeeper@100.101.13.71over Tailscale is a local-only fallback (Tailscale is rejected by the Web-sandbox egress). The authoritative channel doc isdocs/eop-tasks/runbooks/claude-nas-channel.md(written by the cloud agent during T-053a; currently on branchclaude/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 addeop.theestablishers.comas a second hostname on the existingroofkeeper-nastunnel rather than a second cloudflared.- Build needs ~8GB heap (baked:
NODE_OPTIONS=--max-old-space-size=8192); leavePDF_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.
2026-06-28 follow-up β mobile WOPC sign link crashed after auth¶
β 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.
2026-06-28 β Corrected root cause: director signing links need document-scoped access¶
β 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].tsnow 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.tscentralizes 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.tsgained an explicitallowAssignedDirectoroption. 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).