# syntax=docker/dockerfile:1.7 # Multi-stage build for moneyapp. # # Stages: # deps — install all deps with the npm cache mounted, so re-builds # reuse it instead of re-downloading every package. # builder — compile migrate.ts → migrate.js (drops the runtime tsx # dep), build the standalone Next.js bundle, then prune dev # dependencies so only runtime packages survive into runner. # runner — minimal final image: standalone Next.js bundle + the # pruned production node_modules (better-sqlite3's native # binding lives there). # # Notes: # * We do NOT run `npm run db:generate` at build time. The drizzle/ # SQL migrations are committed to the repo, so generating them on # every build is wasted work and a frequent source of permission / # filesystem errors during `docker build`. # * `output: "standalone"` in next.config.ts means we don't need to # copy the entire builder node_modules into runner — just the bits # standalone marks as external (better-sqlite3, drizzle). FROM node:22-bookworm-slim AS deps WORKDIR /build RUN apt-get update && apt-get install -y --no-install-recommends \ python3 build-essential \ && rm -rf /var/lib/apt/lists/* COPY app/package.json app/package-lock.json* ./ RUN --mount=type=cache,target=/root/.npm npm ci FROM node:22-bookworm-slim AS builder WORKDIR /build COPY --from=deps /build/node_modules ./node_modules COPY app/ ./ ENV NEXT_TELEMETRY_DISABLED=1 # Compile the TS migrator into a single self-contained JS file so the # runtime image doesn't need tsx (50+ MB) just to run migrations. # better-sqlite3 + drizzle stay external so they resolve from # node_modules at runtime. RUN npx --yes esbuild src/db/migrate.ts \ --bundle --platform=node --target=node22 \ --external:better-sqlite3 --external:drizzle-orm \ --outfile=migrate.js # Next.js's "Collecting page data" phase imports server modules to # gather route metadata. Auth.js + the Drizzle adapter open a sqlite # connection on import, so /data has to exist and AUTH_SECRET has to # be non-empty *during the build* even though both get overridden at # runtime by the real values. RUN mkdir -p /tmp/build-db \ && DATABASE_FILE=/tmp/build-db/moneyapp.db \ AUTH_SECRET=build-time-placeholder-not-used-at-runtime \ AUTH_URL=http://localhost:3000 \ npm run build # Drop devDependencies so the node_modules we copy into runner has # only what production needs (no typescript, drizzle-kit, eslint...). RUN npm prune --omit=dev FROM node:22-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV DATABASE_FILE=/data/moneyapp.db RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* \ && groupadd -r app && useradd -r -g app app \ && mkdir -p /data && chown app:app /data COPY --from=builder /build/public ./public COPY --from=builder /build/.next/standalone ./ COPY --from=builder /build/.next/static ./.next/static COPY --from=builder /build/drizzle ./drizzle COPY --from=builder /build/migrate.js ./migrate.js # Last so it overlays standalone's traced node_modules with the full # pruned tree — that way better-sqlite3's native .node binding (which # standalone marks external) is present at runtime. COPY --from=builder /build/node_modules ./node_modules USER app EXPOSE 3000 VOLUME ["/data"] CMD ["sh", "-c", "node migrate.js && node server.js"]