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:

  1. 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!
  2. 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-debian11

Here 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 build

Curtains 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=dev

Next 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.

building image

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:

image on aws ecr