DEV Community

Jake
Jake

Posted on • Edited on

Building a Minimalist Docker Image with Node, TypeScript

In this article we are going to have a look at creating a production grade docker image with Node.JS, TypeScript.

We are also going to use the speedy web compiler to compile the source code blazing fast.

Here is the summary

Category Tool / Method
Package Manager pnpm
Compilation - Development ts-node via swc
Build - Production tswc

Lets dive right in.

Now break the steps and optimise the build image one by one.

Stage 1 : Prepare base image

FROM node:20-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable COPY . /app WORKDIR /app 
Enter fullscreen mode Exit fullscreen mode

We're enabling pnpm using corepack. Your package.json should have "packageManager": "pnpm@8.6.6". You can refer the complete package.json in the document below.

Stage 2 : Production Dependencies

FROM base AS prod-deps RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile 
Enter fullscreen mode Exit fullscreen mode

--mount=type=cache: Tells Docker to mount a cache during the build process. This cache will store data across builds, improving build speed by reusing data.

id=pnpm: This is an identifier for the cache. If multiple projects use the same identifier, they will share the same cache, although this could be risky due to possible data contamination between unrelated projects.

target=/pnpm/store: Specifies where in the Docker image the cache should be stored.

Stage 3 : Both production and dev dependencies, Build the codebase

FROM base AS build RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN pnpm run build 
Enter fullscreen mode Exit fullscreen mode

Stage 4 : Copy source, production dependencies and run the source.

FROM gcr.io/distroless/nodejs20-debian11 COPY --from=prod-deps /app/node_modules /app/node_modules COPY --from=build /app/dist /app/dist COPY --from=build /app/package.json /app/package.json WORKDIR /app ENV PORT=5000 EXPOSE 5000 CMD [ "dist/index.js"] 
Enter fullscreen mode Exit fullscreen mode

The image size is 160mb

Comparing against my old simple docker setup. Old setup was based on a alpine image. The build image size was round 650mb

  1. No proper build stage
  2. Ships with development dependencies as well
FROM node:18.16.0-alpine RUN apk add \ curl \ git \ && rm -rf /var/cache/* \ && mkdir /var/cache/apk RUN mkdir -p /app WORKDIR /app RUN mkdir -p /bin && curl -fsSL "https://github.com/pnpm/pnpm/releases/download/v8.6.3/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm; ENV PATH /app/node_modules/.bin:$PATH ADD package.json pnpm-lock.yaml .npmrc /app/ RUN pnpm install ADD . /app RUN pnpm run build EXPOSE 5000 CMD [ "pnpm", "start" ] 
Enter fullscreen mode Exit fullscreen mode

The secret sauce - distroless image

A "distroless" image is a stripped-down container image that contains only the application and its runtime dependencies. Just your application dependencies and nothing else. No build tools, shell, package managers or anything. You can check that on the 4th stage. Thanks Google !

Here are the advantages

  1. Minimised attack surface. We can just focus on the attacks on the app layer.
  2. Small image size.
  3. Improved performance and reduced storage utilisation
  4. As the size of much smaller, it can be pulled and deployed pretty fast.
  5. With fewer components, there are fewer elements to manage and update.

Why Speedy Web Compiler ?

SWC is a super-fast TypeScript / JavaScript compiler written in Rust. We can use SWC for both development and production environments as well. In this setup we are using swc to speed up the compilation time of both development and production.

In development environment swc is used along with ts-node and in production we're using tswc, which compiles the files using swc

Here is the complete Dockerfile

FROM node:20-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable COPY . /app WORKDIR /app FROM base AS prod-deps RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile FROM base AS build RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN pnpm run build FROM gcr.io/distroless/nodejs20-debian11 COPY --from=prod-deps /app/node_modules /app/node_modules COPY --from=build /app/dist /app/dist COPY --from=build /app/package.json /app/package.json WORKDIR /app ENV PORT=5000 EXPOSE 5000 CMD [ "dist/index.js"] 
Enter fullscreen mode Exit fullscreen mode

Here is the tsconfig.json

{ "compilerOptions": { "target": "ESNext", "moduleResolution": "node", "module": "CommonJS", "outDir": "dist/", "esModuleInterop": true, "resolveJsonModule": true }, "ts-node": { "esm": true, "experimentalSpecifierResolution": "node", "files": true, "swc": true, "esModuleInterop": true }, "exclude": ["node_modules"], "include": ["src/**/*.ts", "src/*.ts"] } 
Enter fullscreen mode Exit fullscreen mode

Here is the package.json

{ "name": "docker-typescript-pnpm", "version": "1.0.0", "private": true, "scripts": { "dev": "nodemon", "build": "time tswc", "start": "node dist/index.js" }, "packageManager": "pnpm@8.6.6", "dependencies": { "cors": "^2.8.5", "dotenv": "4.0.0", "express": "^4.18.2", "helmet": "^5.1.1" }, "devDependencies": { "@swc/core": "^1.3.78", "@types/cors": "^2.8.13", "@types/express": "^4.17.17", "@types/node": "^20.5.1", "nodemon": "^2.0.22", "ts-node": "^10.9.1", "tswc": "^1.2.0", "typescript": "^5.1.6" } } 
Enter fullscreen mode Exit fullscreen mode

Here is the source code for this setup : https://github.com/JacobSamro/docker-typescript-pnpm

Top comments (0)