Tanveer Hassan

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


Tanveer Hassan

Hi, I'm a software engineer who lives and works in Dhaka building useful things. Follow me on Twitter