Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Docker and Docker Compose for frontend and Node.js development

Using Docker for development has a great advantage of unifying development environments across the development team and provides a consistent and reproducible experience for everyone.

There are a few things you need to understand about how Docker and Docker Compose work to successfully set it up for development and is specific to Node.js or frontend development. I often come across very complex setups that become difficult to debug. This time I would like to show you the minimal viable setup that you can maintain and still fulfill the most common requirements:

  • For development, reload code when you modify it locally to see or test the changes. This assumes your development server (either webpack or nodemon) runs on npm run start.
  • For development, isolate the container's and host's node_modules. This prevents native dependencies from being copied across different systems allowing developers to run your project either locally or with Docker.
  • For production, use a lightweight container with minimal dependencies. This assumes your setup builds your project (if needed) on npm run build.

I've published a repository on GitHub with the example using this setup.

Dockerfile

Let's start with a Dockerfile.

FROM node:16.1.0-buster AS builder
WORKDIR /app

COPY package.json /app/

# with Yarn
COPY yarn.lock /app/
RUN yarn install --frozen-lockfile

# or with NPM
#COPY package-lock.json /home/app/
#RUN npm ci

COPY . .

RUN npm run build

FROM nginx:1.19.10-alpine
COPY --from=builder /app/build /usr/share/nginx/htmlCOPY nginx.default.conf /etc/nginx/conf.d/default.conf

The most notable about this Dockerfile is that it uses a multi-stage build. We use the complete builder image to develop and build our project. Then we can leverage a simple and lightweight image to copy the build result and run it in a separate container. This approach helps optimize Docker containers but also increases security. The fewer dependencies, the fewer potential attack vectors. This is why Alpine Linux is the de facto standard choice for a final base image.

Another optimization is to copy the package.json and lock file separately from the rest of the code. In the case of the monorepo where you have multiple package.json files, copy them as well. We can make better use of Docker caching by installing dependencies before coping rest of the files. This way we prevent reinstalling dependencies unless package.json or lock file changes.

Docker Compose

The Dockerfile defined all steps for the production build and, in this particular case, serves files with Nginx on port 80. We need a different setup for development with Docker Compose.

This is the docker-compose.yml.

version: "3.9"
services:
  app:
    build:
      context: .
      target: builder
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules    command: npm run start

The container build stop at the builder target, which gives us the node image we use for development. Expose port 3000 to the host on which our server runs and start the development server.

The first volume mounts our repository root directory from the host to the container's working directory. The second volume is an anonymous persistent volume that mounts to node_modules and isolates it from the host’s node_modules.

There are a few different ways to address the issue with mixing container's and host's node_modules, but the solution with the anonymous persisting volume is my favorite. It doesn't require changing where node modules are installed. It might be easy to do with yarn and NPM v6, but at the time I write this, NPM v7 dropped the --prefix/$NODE_PATH support for npm ci. Not moving node modules allows for a similar experience whether you develop locally or in Docker due to the same directories structure.

# Recreate anonymous volumes
docker-compose up -V

The downside of this solution is that Docker retrieves the node modules from the anonymous volume used for the previous launch. You might end up with a stale copy of node modules compared to what is in the freshly pulled image. There's an easy fix for that, just run docker-compose with -V or --renew-anon-volumes.

Wrap up

There are three key points to successful Docker setup for Node.js development to guarantee you an optimal developer experience.

  1. Use multi-step for build performance and security in production.
  2. Install dependencies and then copy source files for faster image builds.
  3. Isolate container's and host's node modules for supporting dual development.

I've published a repository on GitHub with the example using this setup. You can read more about volumes for node modules to learn other ways to isolate them.

Photo by Abigail Lynn on Unsplash.