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 Dockerfile
and 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.
node-app
├── Dockerfile
├── package.json
└── src
└── server.js
1. Simple Dockerfile Example
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
The app will be available at http://localhost:8080
. Use Ctrl+C
to quit.
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.
$ docker run --rm -it -p 8080:8080 -v $(pwd):/app \
node-docker-dev bash
root@id:/app# node src/server.js
2. Hot Reloading with Nodemon
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.
$ cd node-docker
$ docker build -t node-hot-reload-docker .
$ docker run --rm -it -p 8080:8080 -v $(pwd):/app \
node-hot-reload-docker bash
root@id:/app# nodemon src/server.js
All edits in theapp
directory 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:
$ docker run --rm -it --init -p 8080:8080 -v $(pwd):/app \
node-docker-dev bash
4. Serving Static Files
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.
Join the discussion on Reddit / HackerNews :)