Minimal Docker Image Packaging for 3D Web Technology Projects

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.

TinoBritty

© 2024 Tino Britty

Instagram 𝕏 GitHub