DEV Community

MT
MT

Posted on • Originally published at chi.miantiao.me on

Minimal Docker Image Packaging for Vite SSR Projects

Recently, I've been preparing to migrate projects hosted on Cloudflare, Vercel, and Netlify to my own VPS to run via Docker. I revisited Docker image packaging. However, even a small project ended up being packaged into a 1.05GB image, which is clearly unacceptable. So, I researched minimal Docker image packaging for Node.js projects, reducing the image size from 1.06GB to 135MB.

The example project is an Astro project using Vite as the build tool, running in SSR mode.

Version 0

The main idea is to use a minimal system image, opting for the Alpine Linux image.

Following the Astro official documentation for Server-Side Rendering (SSR), I replaced the base image with node:lts-alpine, and switched from NPM to PNPM. The resulting image size was 1.06GB, which is the worst-case scenario.

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 
Enter fullscreen mode Exit fullscreen mode
docker build -t v0 . [+] Building 113.8s (11/11) FINISHED docker:orbstack => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 346B 0.0s => [internal] load metadata for docker.io/library/node:lts-alpine 1.1s => [internal] load .dockerignore 0.0s => => transferring context: 89B 0.0s => [1/6] FROM docker.io/library/node:lts-alpine@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 0.0s => [internal] load build context 0.2s => => transferring context: 240.11kB 0.2s => CACHED [2/6] RUN corepack enable 0.0s => CACHED [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 => => exporting layers 13.4s => => writing image sha256:653236defcbb8d99d83dc550f1deb55e48b49d7925a295049806ebac8c104d4a 0.0s => => naming to docker.io/library/v0 
Enter fullscreen mode Exit fullscreen mode

Version 1

The main idea is to first install production dependencies, creating the first layer. Then install all dependencies, package to generate JavaScript artifacts, creating the second layer. Finally, copy the production dependencies and JavaScript artifacts to the runtime environment.

Following the multi-stage build (using SSR) approach, I reduced the image size to 306MB. This is a significant reduction, but the drawback is that it requires explicitly specifying production dependencies; if any are missed, runtime errors will occur.

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 
Enter fullscreen mode Exit fullscreen mode
docker build -t v1 . [+] Building 85.5s (15/15) FINISHED docker:orbstack => [internal] load build definition from Dockerfile 0.1s => => transferring dockerfile: 680B 0.0s => [internal] load metadata for docker.io/library/node:lts-alpine 1.8s => [internal] load .dockerignore 0.0s => => transferring context: 89B 0.0s => [base 1/4] FROM docker.io/library/node:lts-alpine@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 0.0s => [internal] load build context 0.3s => => transferring context: 240.44kB 0.2s => CACHED [base 2/4] RUN corepack enable 0.0s => CACHED [base 3/4] WORKDIR /app 0.0s => [base 4/4] COPY package.json pnpm-lock.yaml ./ 0.2s => [prod-deps 1/1] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile 35.1s => [build-deps 1/1] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 65.5s => [runtime 1/2] COPY --from=prod-deps /app/node_modules ./node_modules 5.9s => [build 1/2] COPY . . 0.8s => [build 2/2] RUN export $(cat .env.example) && pnpm run build 7.5s => [runtime 2/2] COPY --from=build /app/dist ./dist 0.1s => exporting to image 4.2s => => exporting layers 4.1s => => writing image sha256:8ae6b2bddf0a7ac5f8ad45e6abb7d36a633e384cf476e45fb9132bdf70ed0c5f 0.0s => => naming to docker.io/library/v1 
Enter fullscreen mode Exit fullscreen mode

Version 2

The main idea is to inline node_modules into the JavaScript files, ultimately copying only the JavaScript files to the runtime environment.

When I looked into Next.js, I remembered that node_modules could be inlined into JavaScript files, eliminating the need for node_modules. So, I researched and found that Vite SSR also supports this. Therefore, I decided to use the inlining method in the Docker environment, avoiding the need to copy node_modules, and only copying the final dist artifacts, reducing the image size to 135MB.

Changes to the packaging script:

vite: { ssr: { noExternal: process.env.DOCKER ? !!process.env.DOCKER : undefined; } } 
Enter fullscreen mode Exit fullscreen mode

The final Dockerfile is as follows:

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) && export DOCKER=true && 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 
Enter fullscreen mode Exit fullscreen mode
 docker build -t v2 . [+] Building 24.9s (13/13) FINISHED docker:orbstack => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 708B 0.0s => [internal] load metadata for docker.io/library/node:lts-alpine 1.7s => [internal] load .dockerignore 0.0s => => transferring context: 89B 0.0s => [base 1/4] FROM docker.io/library/node:lts-alpine@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 0.0s => [internal] load build context 0.3s => => transferring context: 240.47kB 0.2s => CACHED [base 2/4] RUN corepack enable 0.0s => CACHED [base 3/4] WORKDIR /app 0.0s => CACHED [base 4/4] COPY package.json pnpm-lock.yaml ./ 0.0s => CACHED [build-deps 1/1] RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 0.0s => [build 1/2] COPY . . 1.5s => [build 2/2] RUN export $(cat .env.example) && export DOCKER=true && pnpm run build 15.0s => [runtime 1/1] COPY --from=build /app/dist ./dist 0.1s => exporting to image 0.1s => => exporting layers 0.1s => => writing image sha256:0ed5c10162d1faf4208f5ea999fbcd133374acc0e682404c8b05220b38fd1eaf 0.0s => => naming to docker.io/library/v2 
Enter fullscreen mode Exit fullscreen mode

In the end, the size was reduced from 1.06GB to 135MB, and the build time was reduced from 113.8s to 24.9s.

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.06GB 
Enter fullscreen mode Exit fullscreen mode

The example project is open-source and can be viewed on GitHub.

BroadcastChannel

stat

Top comments (0)