Tim Reynolds

Software & Startups

Streamlining Node.js Container Builds with Docker Multi-Stage Builds

Docker not only provides an excellent way to package your application for deployment but also introduces the possibility of isolating the build process from the limitations of your local machine or CI build agent. Initially, this would have been achieved using the "docker-in-docker" approach, where a build container is used to run linting, testing, and finally building the application container inside it. However, this approach can be cumbersome and, if done incorrectly, can lead to bloated application containers that include NPM registry secrets and poorly cached Docker layers, resulting in slower build times.

To optimize your Docker build process, you should aim for a Dockerfile that looks like this:

FROM node:10-alpine as builder
WORKDIR /build
COPY package.json .
COPY package-lock.json .
RUN npm ci --only=production
RUN cp -R node_modules prod_node_modules
RUN npm ci

# Copy src code and tests into the build container
# Preferably explicitly rather than copying everything
# If doing this, consider using .dockerignore
COPY . .

# Run linting, tests, etc.
RUN npm run lint
RUN npm run test

FROM node:10-alpine
COPY --from=builder /build/prod_node_modules ./node_modules
# Again, be explicit about what you copy
COPY --from=builder /build/ .
CMD ["node", "server.js"]

Let's go through each section:

  1. The setup uses multiple FROM statements, which is the multi-stage feature of Docker, well-covered in their documentation. It's crucial that these containers share the same OS, as dependencies will be installed in one container and then copied into another. A mismatch of binary versions to OS will lead to headaches later.

  2. We're using Alpine Linux, which will result in the smallest possible production container. However, if npm ci is required to compile anything from source (e.g., Sass), you may lack the necessary OS dependencies. In that case, it's recommended to create a container that includes these dependencies and use it across your containers. Not doing so will require running commands like RUN apk add --update make gcc g++ or similar, slowing down every build.

  3. We copy the package.json and package-lock.json into the container before running npm ci. If these two files haven't changed, Docker uses a cached layer and skips the npm command, saving you the time required to install dependencies. If these files have changed, the RUN commands will be executed.

  4. The npm ci command functions similarly to npm install but is specifically designed for automated environments. It enforces a package.json and lock file that are in sync and provides greater install speed by skipping certain user-oriented features.

  5. Executing npm ci initially with the --only=production flag allows us to take a copy of the runtime-only dependencies for later use in the production image. The local npm cache assists us in the second execution of the command, meaning only development dependencies are installed from the online repository.

  6. After this, you can execute any tests, linting, or similar steps within the builder container before producing the application container.

  7. To create the application container, simply copy the runtime code and dependencies from the builder using the COPY --from command. Note that you'll want to take the production node modules and place them in the node_modules location.

Once done, you can push the outputted container into your Docker registry for deployment. By following this multi-stage build approach, you can ensure efficient, streamlined Node.js container builds with Docker.