Docker for NodeJS applications in production
November 01, 2020
I have always loved using Docker for both development and production. It has made life a lot easier for me both as a developer and a devops guy. I have also always pushed others to write Dockerfiles with their projects so that it becomes easier to deploy and scale.
In this article I will walk you through how I create docker images for NodeJS applications in production. This guide is intended for you if you have some experience and knowledge on both Docker and NodeJS.
The inevitable hello world
For the sake of a running example let’s generate a Nest application:
npx nest new my-app
Run the application without docker (just for the sake of it):
npm start
Browse to http://localhost:3000 and you will be greeted by a Hello World! message!
The Dockerfile
Now that our app works, let’s create the related docker files:
FROM node:14-alpine as builder
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG APP_ENV=development
ENV NODE_ENV=${APP_ENV}
RUN npm run build
RUN npm prune
FROM node:14-alpine
RUN apk --no-cache add curl
ARG APP_ENV=development
ENV NODE_ENV=${APP_ENV}
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/package*.json ./
COPY --from=builder /usr/src/app/dist ./dist
EXPOSE 3000
USER node
CMD [ "npm", "run", "start:prod" ]
Let’s go through the file line by line:
I choose the alpine base image almost all of the time due to such small image sizes. We could use the node:14 image as our builder but since we install node modules in the builder some of the installed modules may not work when copied to a different OS.
To give you an understanding of how much space you save, here is a comparison:
Image | Size |
---|---|
node:14 | 956MB |
node:14-alpine | 131MB |
It’s a no-brainer for me which image I want to go with.
Next I set the workdir to be used.
I copy only the package files, package.json and package-lock.json, and not all of the project. This is so that a layer is created surrounding the npm packages and whenever a file in the application source code changes, the docker builder doesn’t run npm install. However, when there is a change in package.json file that cache layer is invalidated and the package changes get reflected since the new package files will be copied and npm ci will run again.
After copying the package files, I install the npm modules. Notice that I have used npm ci instead of npm install. This command is intended to be used in automated builds (ci environments). It provides vairous pros such as no user prompt etc.
After the node modules are installed we copy the project code base into the builder. Our .dockerignore file makes sure that unintended files are not copied (more on this later).
Then we set the environment variable NODE_ENV from a docker build argument. Notice how I set the environment variable after the node modules are installed and before building the application. This is because I want all the node modules to be installed (including dev dependencies) since I need them to build the application but I want the builder to run on the environment intended. Right after the build is done, the npm prune command removes dev dependencies from node_modules if the NODE_ENV environment variable is set to production. This drastically reduces the final image size. Here is another comparison:
NODE_ENV | Size |
---|---|
development | 299MB |
production | 131MB |
This concludes my build stage. After that, I take another fresh node:14-alpine image, install curl in it (needed for container health checks), and copy only the necessary files and folders into it from the builder.
Expose the required port. Switch user to node (provided in node docker images by default), and start the application using the required command. The reason you will want to change to user node is to avoid using the root user to run the application as it is a bad security practice and may introduce security flaws. Read more on that here.
The .dockerignore file
.dockerignore:
node_modules
npm-debug.log
dist
.git
.env
local
This is kind of like a .gitignore file in the sense that Docker will ignore the listed files and folders when sending files to the build context. Basically these files will not go into the builder when you build the image.
You may want to include sensitive files or folders, build files, git files, vscode or vim files, and last but not least node_modules in your .dockerignore file.
The commands
To build a docker image of our newly created project:
docker build -t my-app:latest --build-arg APP_ENV=production .
Spin up a container with the image:
docker run -p 3000:3000 my-app
You can also create a gzipped tarball of your image:
docker save my-app:latest | gzip > my-app_latest.tar.gz
Bonus
Although this article is about running a Nodejs app using docker in production, here is a docker-compose.yml file that I use for Nest apps with MySQL for development:
docker-compose.yml:
version: "3.8"
services:
app:
build:
context: .
args:
- APP_ENV
command: npm run start:dev
ports:
- 3000:3000
volumes:
- ./:/usr/src/app
- node_modules:/usr/src/app/node_modules/
environment:
APP_ENV: development
APP_PORT: 3000
DB_HOST: db
DB_PORT: 3306
DB_NAME: example_db
DB_USER: root
DB_PASS: example
depends_on:
- db
db:
image: mysql:8
command: --default-authentication-plugin=mysql_native_password
ports:
- 3306:3306
volumes:
- mysqldata:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: example
MYSQL_DATABASE: example_db
adminer:
image: adminer
ports:
- 8080:8080
depends_on:
- db
volumes:
node_modules:
mysqldata:
Commands:
# run the stack
docker-compose up
# take down the stack
docker-compose down
Conclusion
This was a demonstration of my Docker configuration for running a Nodejs application in production.
I have tried to cover how to write a robust Dockerfile and reduce the size of it for faster transfers and lower storage costs. Hope you find this helpful!
Where to go from here:
The built images go to an image repository such as AWS ECR and from there deployed to containers such as AWS ECS Fargate. This whole process can be automated via a CI/CD system such as AWS CodePipeline or CircleCI. You may want to check out CI/CD systems and Docker container deployments in the cloud.
Here is the source code for the above example project: https://github.com/war1oc/docker-for-nodejs-2020-11-01
Hi, I'm a software engineer who lives and works in Dhaka building useful things. Follow me on Twitter