Recently, I’ve been preparing to migrate 3D web applications hosted on platforms like Cloudflare, Vercel, and Netlify to my own VPS using Docker. While exploring Docker image packaging for 3D projects, I noticed that even a simple project resulted in a bloated 1.05GB image, which is far from optimal. Through research and experimentation, I was able to reduce the image size to a mere 135MB while improving the build efficiency.
The project I used for testing is a Three.js-based 3D web application running on a Node.js backend.
Version 0
The initial approach involves using a minimal system image, with Alpine Linux as the base image.
Following standard guidelines for Node.js server applications, I selected the node:lts-alpine
image as the base and used NPM as the package manager. The resulting Docker image, however, turned out to be an enormous 1.05GB—clearly unacceptable for a project intended for smooth deployment.
FROM node:lts-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY . .
RUN pnpm install --frozen-lockfile
RUN export $(cat .env.example) && pnpm run build
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD node ./dist/server/entry.mjs
docker build -t v0 .
[+] Building 113.8s (11/11) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> [internal] load metadata for docker.io/library/node:lts-alpine 1.1s
=> [internal] load build context 0.2s
=> [1/6] FROM docker.io/library/node:lts-alpine 0.0s
=> [2/6] RUN corepack enable 0.0s
=> [3/6] WORKDIR /app 0.0s
=> [4/6] COPY . . 2.0s
=> [5/6] RUN pnpm install --frozen-lockfile 85.7s
=> [6/6] RUN export $(cat .env.example) && pnpm run build 11.1s
=> exporting to image 13.4s
=> naming to docker.io/library/v0
Version 1
Here, the approach is refined to separate production dependencies into distinct layers, reducing the overall image size.
Using a multi-stage build for the Three.js project, I achieved a significant size reduction to 306MB. This approach, however, requires explicit declaration of production dependencies, which increases the risk of runtime errors if anything critical is missed.
FROM node:lts-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
FROM build-deps AS build
COPY . .
RUN export $(cat .env.example) && pnpm run build
FROM base AS runtime
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD node ./dist/server/entry.mjs
docker build -t v1 .
[+] Building 85.5s (15/15) FINISHED
=> [internal] load build definition from Dockerfile 0.1s
=> [internal] load metadata for docker.io/library/node:lts-alpine 1.8s
=> [base 4/4] COPY package.json pnpm-lock.yaml ./ 0.2s
=> [prod-deps 1/1] RUN pnpm install --prod --frozen-lockfile 35.1s
=> [runtime 2/2] COPY --from=build /app/dist ./dist 0.1s
=> naming to docker.io/library/v1
Version 2
This approach inlines
node_modules
into JavaScript files, ensuring a leaner runtime environment.
Taking advantage of bundling capabilities like Vite’s noExternal
setting, I managed to inline dependencies into the JavaScript files themselves. This method drastically reduces the runtime environment size by eliminating the need to copy node_modules
. As a result, the image size dropped to just 135MB.
Modifications to the vite.config.js
:
vite: {
ssr: {
noExternal: process.env.DOCKER ? !!process.env.DOCKER : undefined;
}
}
The final Dockerfile:
FROM node:lts-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
FROM base AS build-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
FROM build-deps AS build
COPY . .
RUN export $(cat .env.example) && export DOCKER=true && pnpm run build
FROM base AS runtime
COPY --from=build /app/dist ./dist
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD node ./dist/server/entry.mjs
docker build -t v2 .
[+] Building 24.9s (13/13) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> [internal] load metadata for docker.io/library/node:lts-alpine 1.7s
=> [build 2/2] RUN export $(cat .env.example) && export DOCKER=true && pnpm run build 15.0s
=> exporting to image 0.1s
=> naming to docker.io/library/v2
The size was ultimately reduced from 1.05GB to just 135MB, and build time decreased significantly.
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
v2 latest 0ed5c10162d1 5 minutes ago 135MB
v1 latest 8ae6b2bddf0a 6 minutes ago 306MB
v0 latest 653236defcbb 11 minutes ago 1.05GB
This streamlined packaging process is ideal for deploying performant, scalable 3D web applications. You can explore the project example and implementation on GitHub.