Docker for Node.js in Development and Production

Our main stack is Node.js/JavaScript and, like many teams, we've moved our development and production environments to Docker containers. However, when we started learning Docker, we found that most articles focused on either development or production, and there was nothing about how to organize your Docker setup to be flexible for both cases.
In this article we'll walk you through different use cases and Dockerfile examples for Node.js, explain the reasoning behind each decision, and help you picture what your workflow with Docker should look like. We'll start with a simple example and then move on to more complex scenarios and solutions to keep your development experience consistent, whether you use Docker or not.
Heads up: this guide is long and aimed at readers with different levels of Docker experience; at times the instructions may seem obvious, but we'll try to add relevant points alongside them so you get a complete picture of the final setup.
Prerequisites
Cases covered
- Basic Dockerfile and docker-compose for Node.js
- Nodemon in development, Node in production
- Keeping devDependencies out of the production Docker image
- Using a multi-stage build for images that need node-gyp
Add a .dockerignore file
Before setting up the Dockerfile, add a .dockerignore file to your app's folder. The .dockerignore file excludes the files listed in it from the COPY/ADD commands. More info here
node_modulesnpm-debug.logDockerfile*docker-compose*.dockerignore.git.gitignoreREADME.mdLICENSE.vscode
Basic Dockerfile for Node.js
To keep things clear, we'll start with a basic Dockerfile you can use in simple Node.js projects. By simple we mean your code has no additional native dependencies or build logic.
FROM node:10-alpineWORKDIR /usr/src/appCOPY package*.json ./RUN npm installCOPY . .CMD [ "npm", "start" ]
You'll find something similar in pretty much any article about Docker and Node.js. Let's go over it briefly.
WORKDIR / usr / src / app;
The workdir is a sort of default directory used by the RUN, CMD, ENTRYPOINT, COPY, and ADD instructions. In some articles you'll see people run mkdir /app and then set that as the workdir, but that's not a good practice. Use a folder that already exists, /usr/src/app, which is better suited for this.
COPY package*.json ./RUN npm install
Another good practice: copy package.json and package-lock.json before copying the app's code into the container. Docker will cache the installed node_modules as a separate layer; that way, if you change the app's code and run the build, node_modules won't be reinstalled unless you've touched package.json. In general, even if you forget to add those lines, you won't run into many issues. Usually you only need to run docker build when package.json changes, which means installing from scratch anyway. In other cases, during development you don't tend to run docker build very often after the first build.
This is where docker-compose comes in
Before running the app in production, you need to develop it. The best way to orchestrate and run your Docker environment is with docker-compose. You define, in a YAML file with simple syntax, the list of containers/services you want to run and their instructions.
version: '3'services: example-service:- build: .- volumes:- - .:/usr/src/app- - /usr/src/app/node_modules- ports:- - 3000:3000- - 9229:9229- command: npm start
In this basic docker-compose.yaml example, the build uses the Dockerfile in the app's folder; then that folder is mounted into the container, and the node_modules installed inside the container during the build aren't overwritten by the ones in your local folder. Port 3000 is exposed to your localhost, assuming you have a web server running. Port 9229 is used to expose the debug port. More info here.
Now run your app with:
docker-compose up
Or use the VS Code extension to do the same thing.
With this command we expose ports 3000 and 9229 of the dockerized app to localhost, mount the current folder with the app at /usr/src/app, and use a small trick so Docker doesn't overwrite the node modules with the ones from the local machine.
So, can you use that Dockerfile in both development and production?
Yes and no.
Differences in CMD
To start, in development you'll normally want the app to reload when a file changes. You can use nodemon for that. But in production you want to run it without that. That means the CMD (command) for development and for production need to be different.
There are a few options to achieve this:
-
Replace the CMD with the command that runs the app without nodemon, which can be a separate command defined in your package.json, for example:
"scripts": {"start": "nodemon --inspect=0.0.0.0 src/index.js","start:prod": "node src/index.js"}In that case, your Dockerfile could look like this:
FROM node:10-alpineWORKDIR /usr/src/appCOPY package*.json ./RUN npm installCOPY . .CMD [ "npm", “run”, "start:prod" ]However, since in development you use the docker-compose file, you can define a different command inside it, just like in the previous example:
version: '3'services:### ... previous instructions- command: npm start -
If the differences are bigger, or you use docker-compose in both development and production, you can create several docker-compose or Dockerfile files depending on what changes. For example, docker-compose.dev.yml or Dockerfile.dev.
Managing package installation
In general, it's best for the production image to be as lightweight as possible and to avoid installing node modules dependencies that aren't needed in production. This can be solved while keeping a single Dockerfile.
Check your package.json and separate the devDependencies from the dependencies. More info here. In short, if you run npm install with the --production flag or set NODE_ENV to production, no devDependency will be installed. We'll add a few lines to the Dockerfile to handle this:
FROM node:10-alpineARG NODE_ENV=developmentENV NODE_ENV=${NODE_ENV}WORKDIR /usr/src/appCOPY package*.json ./RUN npm installCOPY . .CMD [ "npm", “run”, "start:prod" ]
To customize the behavior we use
ARG NODE_ENV=developmentENV NODE_ENV=${NODE_ENV}
Docker lets you pass build arguments from the docker command or from docker-compose. NODE_ENV=development will be used by default as long as we don't override it with another value. You'll find a good explanation here.
Now, when you build the containers with a docker-compose file, all dependencies will be installed, and when you build for production you can pass production as a build argument and devDependencies will be skipped. If you use CI services to build the containers, you just need to add that option to their configuration. More info here
Multi-stage build for images that need node-gyp
Not every app you run in Docker will use only JS dependencies; some need node-gyp and additional native libraries installed on the operating system.
To solve this we can use multi-stage builds, which let you install and compile all dependencies in a separate container and pass only the result of the installation to the final container, with no leftovers. The Dockerfile could look like this:
// The instructions for the first stageFROM node:10-alpine as builderARG NODE_ENV=developmentENV NODE_ENV=${NODE_ENV}RUN apk --no-cache add python make g++COPY package*.json ./RUN npm install// The instructions for second stageFROM node:10-alpineWORKDIR /usr/src/appCOPY --from=builder node_modules node_modulesCOPY . .CMD [ "npm", “run”, "start:prod" ]
In this example, in the first stage we install and compile all dependencies according to the environment, and in the second, we copy the node_modules we'll use in both development and production.
The line RUN apk --no-cache add python make g++ may vary from project to project, since you'll likely need other dependencies.
COPY --from=builder node_modules node_modules
In that line we copy the node_modules folder from the first stage into the node_modules folder of the second. Since in the second stage we set WORKDIR to /usr/src/app, node_modules will be copied into that folder. Summary
We hope this guide helped you understand how to organize your Dockerfile so it works for both development and production. We can sum up our tips like this:
- Try to unify the development and production Dockerfile; if that doesn't work, split them.
- Don't install development node_modules in production builds.
- Don't leave the native extension dependencies needed for node-gyp and node modules installation in the final image.
- Use docker-compose to orchestrate your development environment.
- To orchestrate your Docker containers in production, it's up to you: docker-compose, Docker Swarm, or Kubernetes.