# ---------- Build stage ---------- # glibc (Debian) base: the lockfile's native binaries (@rollup/rollup-linux-x64-gnu, # @esbuild/linux-x64) are built against glibc. alpine uses musl and would fail to load them. FROM node:22-slim AS build WORKDIR /app # Copy lockfile + package manifests first so this layer is cached when only source changes. COPY package.json package-lock.json ./ COPY apps/web/package.json ./apps/web/package.json # SINGLE install. --include=optional brings the platform-native Rollup/esbuild binaries # Vite needs. Never add a second `npm ci --workspace=apps/web` here: it reinstalls node_modules # in a way that drops `vite` from the bin path, producing `sh: 1: vite: not found` (exit 127). # Never use --no-optional / --omit=optional either: it omits @rollup/rollup-linux-x64-gnu # and the Vite build crashes with "Cannot find module @rollup/rollup-linux-x64-gnu". RUN npm ci --include=optional --no-audit --no-fund # Copy the app source only (keeps the build context small; .dockerignore excludes node_modules). COPY apps/web ./apps/web # Build the static site. We call vite directly via its installed binary instead of the # `npm run build` workspace script, so the build never depends on npm's script PATH resolution. # generate-llms.js is optional SEO scaffolding (`|| true`); vite build is the real step. RUN cd apps/web \ && (node tools/generate-llms.js || true) \ && node ../../node_modules/vite/bin/vite.js build --outDir ../../dist/apps/web # ---------- Production stage ---------- # Pure static hosting — no Node.js runtime in the final image. FROM nginx:stable-alpine RUN apk add --no-cache curl COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/dist/apps/web /usr/share/nginx/html EXPOSE 80 HEALTHCHECK --interval=15s --timeout=5s --start-period=5s --retries=3 \ CMD curl -f http://127.0.0.1/ || exit 1 CMD ["nginx", "-g", "daemon off;"]