One of my biggest frustrations when setting up NodeJS deployment using Docker is the excessive size of the resulting image compared to the features it actually contains.
While I can understand a 2GB ISO file for Ubuntu Desktop, given its extensive feature set, it’s hard to justify a Docker image exceeding 1GB for a NodeJS app with only 10 endpoints. It’s simply too much!
This bloat occurs because when we package the node_modules
folder into our Docker image, it includes numerous files that aren’t used at runtime. Next.js addressed this issue a while ago, though I can’t recall the exact command as I no longer use Next. However, I discovered the underlying library they employ, called @vercel/nft
, which stands for Node File Trace.
This library works by tracing all files imported by a given entrypoint file and returning the paths of all dependencies needed at runtime. It’s essentially similar to the dead code elimination or tree shaking process used in Vite or Rollup.
By implementing this approach with just two files, I managed to reduce my NodeJS Docker image size from 2GB to approximately 430MB – a significant improvement!
prune.js
import { nodeFileTrace } from '@vercel/nft';
import { resolve } from 'path';
import path from 'path';
import fs from 'fs';
const files = [resolve(process.cwd(), 'dist/server/entry.mjs')];
const { fileList } = await nodeFileTrace(files);
function copyFile(sourcePath, destinationPath) {
// Check if source path is valid
if (!sourcePath) {
console.error('Invalid source path provided.');
return false;
}
// Get filename and extension from source path
const basename = path.basename(sourcePath);
// Create the destination folder if it doesn't exist
try {
fs.mkdirSync(destinationPath.replace(basename, ''), { recursive: true });
} catch (err) {
console.error('Error creating destination directory:', err);
return false;
}
// Check if source file exists
const sourceStats = fs.statSync(sourcePath);
if (!sourceStats.isFile()) {
console.error('Source path does not point to a valid file.');
return false;
}
// Copy the file from source to destination with same filename and extension
try {
fs.copyFileSync(sourcePath, destinationPath);
console.log(
`File copied successfully from ${sourcePath} to ${destinationPath}`,
);
return true;
} catch (err) {
console.error('Error copying file:', err);
return false;
}
}
for (const f of fileList) {
copyFile(resolve(process.cwd(), f), resolve(process.cwd(), 'out', f));
}
Dockerfile
FROM node:22-slim AS base
RUN corepack enable
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
FROM base AS installer
WORKDIR /app
COPY scripts/prune.js scripts/prune.js
COPY package.json .
COPY pnpm-lock.yaml .
RUN echo "node-linker=hoisted" > .npmrc
RUN echo "symlink=false" >> .npmrc
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
FROM installer AS builder
COPY . .
ENV NODE_ENV=production
RUN pnpm build
RUN node scripts/prune.js
FROM node:22-slim AS executable
WORKDIR /app
RUN npm install pm2 -g
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/out/node_modules /app/node_modules
COPY drizzle /app/drizzle
COPY public /app/public
COPY ecosystem.config.js /app/ecosystem.config.js
ENV NODE_ENV=production
CMD ["pm2-runtime", "ecosystem.config.js"]