An Exhaustive Guide to Writing Dockerfiles for Node.js Web Apps
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).
nodemonfor hot reloading during development.
- Optimising for Docker cache layers — placing commands in the right order so that
npm installis executed only when necessary.
- Serving static files (bundles generated via React/Vue/Angular) using
- Using multi-stage
alpinebuild to reduce final image size for production.
- #ProTips — 1) Using COPY over ADD 2) Handling CTRL-C Kernel Signals using
If you’d like to jump right ahead to the code, check out the GitHub repo.
- Simple Dockerfile and .dockerignore
- Hot Reloading with nodemon
- 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
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 version
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.
.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
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
$ 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 the
appdirectory 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.
In your Dockerfile, prefer COPY over ADD unless you are trying to add auto-extracting tar files, according to Docker’s best practices.
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
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
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
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.