Build a Node app
The goal for a Node service is a final image that carries your built output and your production node_modules, and nothing else. No dev dependencies, no source you do not ship, no package-manager cache. A three-stage build gets you there: resolve production dependencies once, build with the full dependency set in parallel, then assemble a clean runtime stage from the two.
These build on ghcr.io/quenchworks/images/node:24. There is also ghcr.io/quenchworks/images/pnpm:10, a node base with pnpm already enabled through corepack, which saves the corepack enable step in the pnpm variant.
The multi-stage Dockerfile
# Build stage: pnpm is already enabled on the pnpm base image.FROM ghcr.io/quenchworks/images/pnpm:10 AS buildWORKDIR /app
# Resolve production dependencies on their own so the runtime stage can# copy just these. A cache mount keeps the pnpm store warm across builds.COPY package.json pnpm-lock.yaml ./RUN ["pnpm", "install", "--prod", "--frozen-lockfile"]RUN ["cp", "-r", "node_modules", "/prod_modules"]
# Now install everything (incl. dev deps) and build.RUN ["pnpm", "install", "--frozen-lockfile"]COPY . .RUN ["pnpm", "run", "build"]
# Runtime stage: a slim node base, nonroot, with only prod deps + dist.FROM ghcr.io/quenchworks/images/node:24 AS runtimeWORKDIR /appENV NODE_ENV=productionCOPY --from=build /prod_modules ./node_modulesCOPY --from=build /app/dist ./distCOPY --from=build /app/package.json ./package.jsonUSER 1001EXPOSE 3000CMD ["node", "dist/server.js"]FROM ghcr.io/quenchworks/images/node:24 AS buildWORKDIR /app
# Production dependencies only, into their own copy.COPY package.json package-lock.json ./RUN ["npm", "ci", "--omit=dev"]RUN ["cp", "-r", "node_modules", "/prod_modules"]
# Full install (incl. dev deps) and build.RUN ["npm", "ci"]COPY . .RUN ["npm", "run", "build"]
FROM ghcr.io/quenchworks/images/node:24 AS runtimeWORKDIR /appENV NODE_ENV=productionCOPY --from=build /prod_modules ./node_modulesCOPY --from=build /app/dist ./distCOPY --from=build /app/package.json ./package.jsonUSER 1001EXPOSE 3000CMD ["node", "dist/server.js"]# Yarn (Classic) is preinstalled on the yarn base image.FROM ghcr.io/quenchworks/images/yarn:1 AS buildWORKDIR /app
# Production dependencies only.COPY package.json yarn.lock ./RUN ["yarn", "install", "--frozen-lockfile", "--production"]RUN ["cp", "-r", "node_modules", "/prod_modules"]
# Full install (incl. dev deps) and build.RUN ["yarn", "install", "--frozen-lockfile"]COPY . .RUN ["yarn", "build"]
FROM ghcr.io/quenchworks/images/node:24 AS runtimeWORKDIR /appENV NODE_ENV=productionCOPY --from=build /prod_modules ./node_modulesCOPY --from=build /app/dist ./distCOPY --from=build /app/package.json ./package.jsonUSER 1001EXPOSE 3000CMD ["node", "dist/server.js"]What each stage does
- Prod-deps. Install only production dependencies, then copy them to a fixed path (
/prod_modules). This is the set the final image needs, kept apart from the dev dependencies the build pulls in next. - Build. Install the full dependency set, copy the source, and run the build. This stage holds the compiler, the dev dependencies, and the source, none of which ship.
- Runtime. Start fresh from
node:24, copy in the productionnode_modulesand the builtdist/, drop touid 1001, and start. The toolchain stays behind.
Next
- Running a static frontend instead of a Node server? Build with
node, then serve the built assets fromnginx, or ship a self-contained binary ontostatic. - Pin by digest once you settle on a base, so every build runs exactly what was scanned and signed.