Dockerfiles for Node and TypeScript: Slim Containers with Distroless
Andreas Bergström
Posted on May 20, 2023
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:
ARG BUILD_IMAGE=node:20.1.0
ARG 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.
# Build stage
FROM $BUILD_IMAGE AS build-env
COPY . /app
WORKDIR /app
RUN 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.
# Prepare production dependencies
FROM $BUILD_IMAGE AS deps-env
COPY package.json package-lock.json ./
RUN 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.
# Create final production stage
FROM $RUN_IMAGE AS run-env
WORKDIR /usr/app
COPY --from=deps-env /node_modules ./node_modules
COPY --from=build-env /app/build ./build
COPY 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!
ENV NODE_ENV="production"
EXPOSE 8080
CMD ["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:
ARG BUILD_IMAGE=node:20.1.0
ARG RUN_IMAGE=gcr.io/distroless/nodejs20-debian11
# Build stage
FROM $BUILD_IMAGE AS build-env
COPY . /app
WORKDIR /app
RUN npm ci && npm run build
# Prepare production dependencies
FROM $BUILD_IMAGE AS deps-env
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Create final production stage
FROM $RUN_IMAGE AS run-env
WORKDIR /usr/app
COPY --from=deps-env /node_modules ./node_modules
COPY --from=build-env /app/build ./build
COPY package.json ./
ENV NODE_ENV="production"
EXPOSE 8080
CMD ["build"]
And here is the size of a fastify api built with this Dockerfile when hosted on AWS ECR:
Posted on May 20, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.