Skip to main content

Deployment & Build

Docker image, Vite/Rollup externalization gotchas, staging deploy workflow.

Deep-dive doc split out of .claude/rules/architecture.md (which is now an index). Append new findings about this area here, not to the index.

Docker image

Dockerfile is minimal: FROM node:22-alpine, COPY . ., RUN npm ci, RUN npm run build, CMD npm run start. Two consequences:

  • npm ci does NOT use --omit=dev — devDependencies (tsx, prisma, vitest, etc.) ship in the production image. This is what makes tsx scripts/...ts work from the DO console without further changes. If the image is ever slimmed via --omit=dev, all tsx-based scripts will break unless tsx is moved to dependencies.
  • COPY . . copies the entire working tree, including scripts/ and src/. So scripts that import from ../src/... continue to resolve at runtime; nothing is pruned.

Vite/Rollup Build — dependencies Are Externalized by Bare Specifier Only (INFRA-611)

vite.config.ts's rollupOptions.external array externalizes third-party packages so npm run build (vite build, emitting dist/index.js as pure ESM per build.lib.formats: ['es']) doesn't bundle them — mostly via ...Object.keys(dependencies) (every package.json dependencies entry, by exact bare specifier), plus a few explicit extra strings/regexes ('express', /^node:/, etc.). A regex entry is required, not the bare-string form, for any package whose code is imported via a deep subpath (e.g. 'nodemailer/lib/mail-composer', not 'nodemailer') — Object.keys(dependencies) only ever contributes exact bare package-name strings, and Rollup's external matching is exact-string (or regex/function) against the full imported specifier, so a subpath import silently falls through and gets bundled even though the bare package name is nominally "external". This was already hit once before and fixed for @shopify/shopify-api/adapters/node-style subpaths via a dedicated /^@shopify\/shopify-api.*$/ regex entry (predates INFRA-611, comment explains the original tree-shaking/dynamic-import symptom).

INFRA-611 gotcha, found live in Staging after INFRA-578 merged: src/clients/gmailClient.ts added import MailComposer from 'nodemailer/lib/mail-composer'; (deep subpath) for the new Gmail-notification client. Since only the bare 'nodemailer' string was externalized (via the dependencies spread), Rollup bundled nodemailer's internal lib/base64 submodule — which does class Base64Encoder extends Transform against Node's stream built-in — and something in Rollup's bundling of that nested CJS chain broke the Transform reference, producing TypeError: Class extends value undefined is not a constructor or null at module-load time, i.e. before Express ever calls app.listen(). The container therefore never binds to its port, which is why DigitalOcean's automated deploy-failure diagnosis reported a "Port binding issue" — a real symptom, but a red herring for the actual root cause. The GMAIL_* env vars being present/absent in DO was a plausible-looking but incorrect first hypothesis; the crash reproduces identically even with all required env vars correctly set, since it happens at import-time, before any env-dependent code runs.

Fix requires two changes together (verified by checking out the merged commit in an isolated git worktree, running the real npm run build + node dist/index.js, and reproducing then resolving the exact stack trace):

  1. Add a regex externalizing the whole package's subpaths — /^nodemailer/ — alongside the existing /^@shopify\/shopify-api.*$/ entry, so Rollup stops bundling any of nodemailer's internals.
  2. Change the import to the fully-specified file path: 'nodemailer/lib/mail-composer/index.js'. This is necessary in addition to (1) — once truly externalized, the specifier is left as-is in the ESM output and Node's native ESM resolver (unlike CJS require) does not auto-resolve directory imports; without the explicit /index.js, boot instead fails with ERR_UNSUPPORTED_DIR_IMPORT.

Rule of thumb: any time a new dependency is consumed via a deep subpath import (rather than the package's main entry), check whether it needs its own regex external entry — the blanket Object.keys(dependencies) spread does not cover this case, and the failure mode (crash at container boot, before the port binds) is easy to misattribute to something else since it only manifests in the real vite build output, not in tsx/dev-mode runs (which don't bundle node_modules at all, so the bug is invisible locally under npm run dev).

Staging Deploy Workflow (.github/workflows/deploy-staging.yml)

Push-to-main: biometsctestbuild (migrate DB, build/push image, rotate tags candidatestaging, prior :staging digest→:previous for rollback; registry ops via DO REST API not doctl, which mishandles multi-registry)→deploy. GC gotcha (INFRA-503): manual doctl registry garbage-collection start step is commented out — DO automated GC now enabled on manufacturing-admin-staging, manual call 412s (manual GC not available while automated GC enabled) and failed build (last step). Left commented (not deleted) for possible revert; don't re-enable while automated GC on. Prod workflow has no GC step.