Dockerfiles for Node and TypeScript: Slim Containers with Distroless
Greetings, fellow coder! Welcome to our magical journey through the land of Docker, Node.js, and TypeScript. Picture this: we are brave knights on a quest to vanquish bloated Docker images and fight off lurking security vulnerabilities. Our weapons? Multistage builds and distroless images!
Multistage Builds: Less is More!
Multistage builds are Docker's equivalent of a transformer robot - a single Dockerfile with multiple identities! These powerful, shapeshifting Dockerfiles help us keep our final Docker image as lean as a sprinter and as clean as a whistle.
Here's the deal: in a Node.js application, there's a crowd of dependencies that are just party crashers. They join the fun during the build process but are total couch potatoes when it's time for the application to run. With multistage builds, we kick these loafers out when they're no longer needed. We install them during the build stage, let them do their thing, and then bid them adieu in the final image.
Distroless Images: Size Zero and Safety Hero
Distroless images are the superheroes of Docker images! They're here to save the day with their minimalist design and robust security. They come packed with only your application and its runtime dependencies. No package managers, shells, or other uninvited guests you'd typically find in a standard Linux distribution.
These cape-wearing images swoop in with two superpowers:
- Reduced attack surface: With fewer elements in the image, villains find fewer opportunities to exploit our container. It's like reducing the number of doors in a fortress!
- Reduced image size: Less clutter, smaller size. This means our Docker image is nimble and efficient, just like a superhero should be.
The most battle-tested and actively maintained docker images for this is maintained by Google as part of the Google Container Tools. This is what we will use for our Dockerfile, and other than Node you'll also find images for Java and Python.
Now, let's dive into the deep end of our Dockerfile and dissect it, piece by piece:
1ARG BUILD_IMAGE=node:20.1.0
2ARG RUN_IMAGE=gcr.io/distroless/nodejs20-debian11Here we're setting the stage. The ARG instruction is our backstage crew, setting up BUILD_IMAGE as our build-stage costume and RUN_IMAGE as our final runtime-stage ensemble.
1# Build stage
2FROM $BUILD_IMAGE AS build-env
3COPY . /app
4WORKDIR /app
5RUN npm ci && npm run buildCurtains up! This is our build stage. We're putting our application code under the spotlight in the build-env image. The backstage crew swiftly prepares the set with npm ci and npm run build to install the dependencies and compile our TypeScript script into JavaScript.
1# Prepare production dependencies
2FROM $BUILD_IMAGE AS deps-env
3COPY package.json package-lock.json ./
4RUN npm ci --omit=devNext scene: another build stage. Here, we're like bouncers at a club, only letting in the cool, production dependencies with the --omit=dev flag. This ensures the party crashers (aka devDependencies) don't sneak into the runtime. The result? A sleek, slim Docker image.
1# Create final production stage
2FROM $RUN_IMAGE AS run-env
3WORKDIR /usr/app
4COPY --from=deps-env /node_modules ./node_modules
5COPY --from=build-env /app/build ./build
6COPY package.json ./Now, the final act! We're in the distroless Node.js image, building our grand production at /usr/app. We move in our essential props – node_modules from our deps-env stage and the compiled code from the build-env stage. We even slide in our package.json script for good measure.
This cunning strategy ensures only the VIPs - our essential runtime dependencies and compiled application code - make it to our final Docker image. Thus, our image remains slender, and security risks get sent packing!
1ENV NODE_ENV="production"
2EXPOSE 8080
3CMD ["build"]Finale time! We hoist the NODE_ENV flag to "production", just as a security measure to minimize the risk of running in development mode by accident. The EXPOSE 8080 instruction is like a public service announcement to Docker that our container will be entertaining guests on port 8080 at runtime, though it does not automatically publish the port when running the container.
Last but not least, the CMD ["build"] is the final act in our Docker drama. As this is a distroless base image containing only node, node is the only command you can run using CMD and therefor we can just pass it the build folder of our compiled Javascript and it will run the index.js there.

Curtains close! So, there you have it, fellow knight! Using these best practices, you can craft a Node.js & TypeScript application Docker image that's as swift as a cheetah, as lean as a gazelle, and as secure as a fortress. Multistage builds and distroless images are your trusted squires in this noble quest for optimal, professional, and secure deployments. Now, go forth and conquer the world of containerized applications!
Here is the complete Dockerfile:
1ARG BUILD_IMAGE=node:20.1.0
2ARG RUN_IMAGE=gcr.io/distroless/nodejs20-debian11
3
4# Build stage
5FROM $BUILD_IMAGE AS build-env
6COPY . /app
7WORKDIR /app
8RUN npm ci && npm run build
9
10# Prepare production dependencies
11FROM $BUILD_IMAGE AS deps-env
12COPY package.json package-lock.json ./
13RUN npm ci --omit=dev
14
15# Create final production stage
16FROM $RUN_IMAGE AS run-env
17WORKDIR /usr/app
18COPY --from=deps-env /node_modules ./node_modules
19COPY --from=build-env /app/build ./build
20COPY package.json ./
21
22ENV NODE_ENV="production"
23EXPOSE 8080
24CMD ["build"]And here is the size of a fastify api built with this Dockerfile when hosted on AWS ECR:
