Single-image multistage Dockerfile: bundled frontend + backend, tenant-portable

Altien replaces the upstream backend-only Dockerfile with a three-stage build that produces one image serving both the compiled Express API and the Next.js static export. The `NEXT_PUBLIC_API_BASE_URL` build arg handles same-origin vs split-origin deployments from a single Dockerfile.

infrastructure

Maintained by Allen Morgan · verified on MikeWatch

The build has three stages. frontend-builder (node:22-slim) runs npm run build against frontend/, producing a static export to /app/out. NEXT_PUBLIC_API_BASE_URL is the only build-time arg the frontend needs - empty means same-origin, which is the bundled-deploy case where Express serves both API and frontend. Everything else (auth provider, Entra IDs) is fetched from GET /config at browser startup, so the image doesn't need to be rebuilt per tenant. backend-builder compiles TypeScript to /app/dist. The runtime stage is node:22-slim with LibreOffice installed for DOCX-to-PDF conversion, production node_modules, the compiled backend, the frontend export at /app/public, migration SQL files, and the scripts/install/ directory.

Express picks up /app/public via express.static when the directory exists - local backend dev doesn't have it and falls through to CORS-based separation.

frontend/next.config.ts flips to output: "export". The --legacy-peer-deps flag in the frontend npm ci step is explained in a comment: @opennextjs/cloudflare left over from a previous deploy-target experiment peer-depends on an older Next.js version. The comment says it should be removed in a later cleanup.

A follow-up commit (feec6d3e) adds scripts/install/README.md as a placeholder. The Dockerfile's runtime stage unconditionally COPY scripts/install ./scripts/install, but the OSS fork doesn't ship those scripts. Without the placeholder, docker build and az acr build fail with "COPY failed: file not found in build context". The install route uses fs.existsSync and degrades cleanly when the directory is empty.

So what Worth a look if you want one image for both halves of the app - it simplifies deployment to a single container, and the build-arg split for same-origin vs split-origin is a clean pattern to copy. Skip if you're happy running the frontend and backend separately. The LibreOffice install inflates the image noticeably; if you don't need DOCX conversion you'd want to remove it.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

2 commits from Altien/mikeOssAzure, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
d829a171 feat(build): bundled multistage Dockerfile + static export config Allen Morgan 2026-05-08 ↗ GitHub
commit body
Single image for the application: Next.js builds the frontend as a
static export and the Dockerfile copies the resulting /out tree into
the backend image's /app/public.  The Express server picks it up at
runtime when the directory is present (see the static-frontend block in
backend/src/index.ts).

  * Dockerfile                   - three stages:
      backend-deps  : install + tsc + tree-shake the backend
      frontend-deps : install + next build with NEXT_PUBLIC_API_BASE_URL
                      passed in via --build-arg so the same image source
                      can target same-origin or split-origin deployments
      runtime       : node:lts-alpine + libreoffice (for docx convert)
                      with the built backend, prod node_modules, and
                      the frontend export.

  * .dockerignore                - excludes backend test fixtures,
                                   migrations directory artefacts, the
                                   frontend's .next/ debug output, and
                                   anything under .claude/.

  * frontend/next.config.ts       - output: "export" so the frontend
                                   produces a static SPA bundle that
                                   the backend can serve out of a
                                   single Express process.

  * frontend/.env.local.example   - documents the new minimal env
                                   surface: only NEXT_PUBLIC_API_BASE_URL
                                   plus the optional supabase-mode pair.

  * frontend/package.json + lockfile - bumps the dep set to match the
                                   updated context + lazy-supabase
                                   shape; resend dropped (unused).
feec6d3e fix(build): add scripts/install/README.md placeholder Allen Morgan 2026-05-08 ↗ GitHub
commit body
The runtime stage of the Dockerfile unconditionally COPYs
scripts/install, but that directory was not present in the OSS
fork, so `az acr build` (and any plain `docker build`) failed at
"COPY failed: file not found in build context" before runtime
was reached.

The application code in backend/src/routes/install.ts already
expects this directory may be empty or absent when the OSS image
is built without the marketplace deploy bundle. The runtime route
checks fs.existsSync and degrades cleanly. The Dockerfile was the
mismatch.

This commit ships an empty-by-design placeholder README so the
COPY succeeds. Downstreams that bundle their own operator scripts
drop them alongside this README without Dockerfile changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-199.md from inside the repo you want the changes in.

⬇ Download capture-thread-199.md