Dockerize a Node.js App using a Distroless Image

In a previous post, I covered the deployment of a simple Node.js application on Railway. Today, I'll explain how to dockerize the app using a distroless container image for better security, performance, and manageability.

What is a Google Distroless Image?

Google's Distroless container images are minimal Docker images designed for running applications. These images are built from scratch, signed using Sigstore Cosign, and contain only the minimal set of libraries and packages needed to run an application, making them smaller and more secure than other images like Debian or Alpine. The smallest distroless image, gcr.io/distroless/static-debian11, is around 2 MiB. That's approximately 50% of the size of alpine (~5 MiB), and less than 2% of the size of debian (124 MiB). Distroless images do not contain package managers, shells or other similar programs; since they have a smaller attack surface, they are also less vulnerable to security exploits.

Dockerize a Simple Node.js App using a Distroless Image

First, let's fork the vanilla Node.js web application repository on GitHub. This is a simple "Hello World!" application, and I don't intend to make any structural changes to it. Since distroless images exclude several packages, they are better suited to serving the application than building it. Hence, we'll use multi-stage Docker builds to deploy the Node.js app. Add a Dockerfile file to the repository with the contents below; I'm splitting the stages for readability.

A vanilla, but functional, Dockerfile using the node image would be as below.

FROM node

WORKDIR /app
COPY * /app

RUN npm install

CMD ["node", "index.js"]

Snyk has good posts on choosing a Node.js Docker image and configuring the images; while I'm using some of the guidance to create a hardened image with a smaller footprint, there's obviously a lot more that you should consider before deploying this image into production. Some of my considerations below are:

  • Use the alpine image for building the app instead of node. Alpine uses musl instead of glibc; use Debian slim if you need glibc compatibility.
  • Use the distroless image for serving the app instead of node.
  • Use explicit (and optionally, deterministic) Docker base image tags.
  • Use multistage builds to install dependencies and copy them to the runtime.
  • Install only production dependencies in the Docker image. Use npm ci if integration with a continuous integration system.
  • Run the container as a non-root user account.

The first stage builds the application artifacts using a node:18.15.0-alpine image, sets the environment to production, and installs the required dependencies.

# Stage 1
FROM node:18.15.0-alpine AS base
ENV NODE_ENV production

RUN mkdir -p /app
WORKDIR /app
COPY * /app

RUN npm install --production

The second stage inserts the application artifacts built in the first stage into the runtime distroless image, and launches the index.js file as a nonroot user.

# Stage 2
FROM gcr.io/distroless/nodejs18-debian11

WORKDIR /app
COPY --from=base /app /app

USER nonroot

CMD ["index.js"]

You can use this repository to deploy the app in the next section; I'll be using a pre-built template with the PORT variable configured instead.

Deploy the Dockerized Node.js App on Railway

Railway is a modern app hosting platform that makes it easy to deploy production-ready apps quickly. Railway offers persistent database services for PostgreSQL, MySQL, MongoDB, and Redis, as well as application services with a GitHub repository as the deployment source. For the latter, Railway can automatically determine the application runtime and deploy the service. Railway offers several one-click starters for popular applications, including Node.js. Since we are just testing the waters, Railway's free tier should be sufficient to host the service.

Source: Railway.app

Sign up for an account with Railway using GitHub, and click Authorize Railway App when redirected. Review and agree to Railway's Terms of Service and Fair Use Policy if prompted. Launch the Node.js Distroless one-click starter template (or click the button below) to deploy a dockerized Node.js app instantly on Railway.

You'll be given an opportunity to change the default repository name and set it private, if you'd like. Accept the defaults and click Deploy; the deployment will kick off immediately.

Deploy Node.js Distroless one-click template on Railway

Once the deployment completes, a simple Node.js app will be available at a default xxx.up.railway.app domain - launch this URL to access the app. If you are interested in setting up a custom domain, I covered it at length in a previous post - see the final section here. In conclusion, deploying a dockerized Node.js app on Railway using a distroless image is a straightforward process, made even easier by the one-click template. Go forth and deploy!