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.
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.
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
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
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
There are three key points to successful Docker setup for Node.js development to guarantee you an optimal developer experience.
- Use multi-step for build performance and security in production.
- Install dependencies and then copy source files for faster image builds.
- 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.