An Exhaustive Guide to Writing Dockerfiles for Node.js Web Apps
TL;DR
This post is filled with examples ranging from a simple Dockerfile to multistage production builds for Node.js web apps. Here’s a quick summary of what this guide covers:
Using an appropriate base image (carbon for dev, alpine for production).
Using nodemon for hot reloading during development.
Optimising for Docker cache layers — placing commands in the right order so that npm install is executed only when necessary.
Serving static files (bundles generated via React/Vue/Angular) using serve package.
Using multi-stage alpine build to reduce final image size for production.
#ProTips — 1) Using COPY over ADD 2) Handling CTRL-C Kernel Signals using init flag.
If you’d like to jump right ahead to the code, check out the GitHub repo.
Contents
Simple Dockerfile and .dockerignore
Hot Reloading with nodemon
Optimisations
Serving Static Files
Single Stage Production Build
Multi Stage Production Build
Let’s assume a simple directory structure. The application is called node-app. The top level directory has a Dockerfileand package.json The source code of your node app will be in src folder. For brevity, let’s assume that server.js defines a node express server running on port 8080.
For the base image, we have used the latest LTS versionnode:carbon
During image build, docker takes all files in the context directory. To increase the docker build’s performance, exclude files and directories by adding a .dockerignore file to the context directory.
Typically, your .dockerignore file should be:
.git
node_modules
npm-debug
Build and run this image:
$ cd node-docker
$ docker build -t node-docker-dev .
$ docker run --rm -it -p 8080:8080 node-docker-dev
Now let’s say you want this to work every time you change your code. i.e local development. Then you would mount the source code files into the container for starting and stopping the node server.
nodemon is a popular package which will watch the files in the directory in which it was started. If any files change, nodemon will automatically restart your node application.
We’ll build the image and run nodemon so that the code is rebuilt whenever there is any change inside the app directory.
All edits in theappdirectory will trigger a rebuild and changes will be available live at http://localhost:8080. Note that we have mounted the files into the container so that nodemon can actually work.
3. Optimisations
In your Dockerfile, prefer COPY over ADD unless you are trying to add auto-extracting tar files, according to Docker’s best practices.
Bypass package.json ‘s start command and bake it directly into the image itself. So instead of
$ CMD ["npm","start"]
you would use something like
$ CMD ["node","server.js"]
in your Dockerfile CMD. This reduces the number of processes running inside the container and it also causes exit signals such as SIGTERM and SIGINT to be received by the Node.js process instead of npm swallowing them. (Reference — Node.js Docker Best Practices)
You can also use the --init flag to wrap your Node.js process with a lightweight init system, which will respond to Kernel Signals like SIGTERM (CTRL-C) etc. For example, you can do:
The above Dockerfile assumed that you are running an API server with Node.js. Let’s say you want to serve your React.js/Vue.js/Angular.js app using Node.js.
As you can see above, we are using the npm package serve to serve static files. Assuming you are building a UI app using React/Vue/Angular, you would ideally build your final bundle using npm run build which would generate a minified JS and CSS file.
The other alternative is to either 1) build the files locally and use an nginx docker to serve these static files or 2) via a CI/CD pipleline.
5. Single Stage Production Build
Build and run the all-in-one image:
$ cd node-docker
$ docker build -t node-docker-prod .
$ docker run --rm -it -p 8080:8080 node-docker-prod
The image built will be ~700MB (depending on your source code), due to the underlying Debian layer. Let’s see how we can cut this down.
6. Multi Stage Production Build
With multi stage builds, you use multiple FROM statements in your Dockerfile but the final build stage will be the one used, which will ideally be a tiny production image with only the exact dependencies required for a production server.
With the above, the image built with Alpine comes to around ~70MB, a 10X reduction in size. The alpine variant is usually a very safe choice to reduce image sizes.
Any suggestions to improve the ideas above? Any other use-cases that you’d like to see? Do let me know in the comments.