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:
-
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. -
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 likeRUN apk add --update make gcc g++
or similar, slowing down every build. -
We copy the
package.json
andpackage-lock.json
into the container before runningnpm 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, theRUN
commands will be executed. -
The
npm ci
command functions similarly tonpm install
but is specifically designed for automated environments. It enforces apackage.json
and lock file that are in sync and provides greater install speed by skipping certain user-oriented features. -
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. -
After this, you can execute any tests, linting, or similar steps within the builder container before producing the application container.
-
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 thenode_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.