Do You Follow These Best Practices for Docker and Node.js Production Apps? [+ example Dockerfile]

March 31, 2020 · 4 min read

background

Getting everything right with your Node.js applications in Docker is hard. Features and bugs swallow your precious time, and deadlines are looming over the horizon. Following best practices right now improves your security, decreases deployment times, and saves you headaches later on.

Do you and your team follow all of these? If not, try implementing them in your project.

1. Make sure your NODE_ENV is set to production

At least in production (and preferably in your E2E tests, should you have any), your NODE_ENV environment variable should be set to production. This practice once started with the Express web server, and now many libraries listen for this env variable and enable optimizations or disable some logging/debugging code. This speeds up your application and may increase your security by disabling certain program paths you only need during development.

2. Use the non-root node user provided by most Node.js images

The default Linux user inside Docker containers is root. The root user has elevated privileges. When we use it to run our process inside Docker containers, we increase the likelihood that an attacker can obtain access to the Docker host, should they gain control via our running application.

Solution: All node Docker images have the node user which has no root/sudo privileges. Use it like this in your Dockerfile:

# make sure to setup permissions for files correctly
# see example Dockerfile below
USER node

3. Do not let Node.js run as PID 1

The process that you start first within your Docker container has process ID 1 (PID 1). Processes that run as PID 1 have several additional responsibilities to other processes, such as reaping zombie processes. Node.js is not designed to run with PID 1. If Node.js is running as PID 1, it will not appropriately respond to signals like SIGTERM.

Solution: Use the --init flag of Docker or add Tini within your Dockerfile. This is even advised in Kubernetes scenarios.

# example for alpine
...

RUN apk add --no-cache tini
# Tini is now available at /sbin/tini
ENTRYPOINT ["/sbin/tini", "--"]

CMD ["node", "index.js"]

Want to learn more about why Tini is awesome? Read this explanation by its creator. You'll learn a whole lot about Docker and processes.

4. Avoid using npm in CMD

Using npm in your CMD definition adds additional startup time to your containers and at least one running process. npm can also swallow exit signals such as SIGINT or SIGTERM.

Solution: Use node directly.

CMD ["node", "index.js"]

5. Use LTS versions of Node.js and update regularly

Even-numberer Node.js versions are Long Term Support (LTS) releases. They receive security and stability updates for two and a half years after a newer current version is released. Yes, many teams already use LTS versions of Node.js. But using an LTS version does not help if you don't regularly update your base image. Updating your base image does update not only your version of Node.js but also the version of the underlying Linux system and packages. Even with tiny images like Alpine, it is crucial to stay up-to-date with security updates of your distribution.

Solution: Add a helper tool like diun to your CI pipeline to get (Slack) notifications when an update to your base image is available.

6. Keep the 12-factor methodology in mind

Herok co-founder Adam Wiggins created the 12-factor methodology. It helps to improve developing, building, deploying, and running decoupled stateless microservices, but in my opinion, it also helps with single services. Using it improves the time and cost to onboard new developers to a project and increases portability between execution environments. 12-factor apps are also suitable for deployment on modern cloud platforms and play nicely with containers. They minimize the divergence between development and production and enable continuous deployment. As an additional benefit, scaling up and scaling out is more straightforward with apps that follow these methodologies.

You can find more info about the 12-factor method on the 12-factor homepage.

7. Use the builder pattern for smaller and more secure images

If you need to build your application before running it, you probably have a build step in your Dockerfile. Your initial Dockerfile might look like this:

FROM node:10
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

EXPOSE 8080
CMD [ "node", "dist/index.js" ]

This Dockerfile is already somewhat optimized for build speed/caching, because only the package*.json files are copied over before installing dependencies. With this, npm install is only called when something changes in package.json.

The step RUN npm run build, bundles your whole application code. Now, after building your application, you don't need your dev dependencies and unbundled application code anymore, but they are still included in the original image.

Solution: Use multi-stage Dockerfiles.

# build stage
FROM node:12.16.1-alpine3.11 AS build
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# cmd stage
FROM node:12.16.1-alpine3.11
WORKDIR /usr/src/app
COPY package*.json ./
RUN NODE_ENV=production npm install
COPY --from=appbuild /usr/src/app/dist ./dist

EXPOSE 8080
CMD [ "node", "dist/index.js" ]

Finishing words

There are many things to consider when using Node.js with Docker that are not obvious during the initial research. I hope this article helps you to build more stable and secure Docker images in the future.

BONUS

I created an example Dockerfile based on the recommendations in this article. You can find it on this GitHub page.