DEV Community

Cover image for Level up your NodeJS Dockerfiles with these 3 tips ⚡🐋
Arnau Espin
Arnau Espin

Posted on

Level up your NodeJS Dockerfiles with these 3 tips ⚡🐋

Dockerfiles are the blueprints for your containers. They are simple text files with a list of commands that you would normally execute manually to create a container image. The Dockerfile is the source code for your container.

Dockerfiles are easy to write, but they can get complicated quickly. Here are some tips to help you write better Dockerfiles.

I am going to share with you 3 tips that I have learned over the years. These tips will help you write better Dockerfiles. They will make your containers more stable and secure. They will also make your containers smaller and faster. I hope you find them useful.

If your Dockerfile looks like this, it can be improved:

FROM node:lts-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "run", "start"]
Enter fullscreen mode Exit fullscreen mode

1- Choose the right base image

A base image is the starting point for your container. It is the first line in your Dockerfile. The base image is the foundation for your container. It is the operating system and the software that you will use to build your container.

Alpine is the most popular base image for Docker containers. It is a lightweight Linux distribution that is optimized for containers. It is small, fast, and secure. It is a good choice for most containers. But... it is not officially supported by Docker. It is maintained by the Alpine Linux community.

A better approach would be to use the bullseye base image. It is the official Debian base image for Docker. It is maintained by the Debian community. It is a good choice for most containers. It is small, and fast, and if you go for the slim variant, it is also secure and more lightweight.

FROM node:20.9.0-bullseye-slim
Enter fullscreen mode Exit fullscreen mode

Also, specify the version of the base image. This will ensure that your container will always use the same version of the base image. This will make your container more stable and secure. lts is not a version. It is a tag. It is not recommended to use tags for base images. Use the version instead.

2- Do you need to copy everything?

Probably not, I am pretty sure you do not need a .prettierignore or .gitignore among other files in a production Docker container.

You can solve this in 2 ways:

  • Specify a list of COPY directives for each file/folder.

  • Specify the list of what you do NOT want to copy in a .dockerignore file.

I prefer specifying a list of the files/folders I want to copy to the image. Despite this, I would recommend you to use a .dockerignore file to avoid copying unnecessary files to the image as it is a good practice.

Also, for security reasons, you should use a user with fewer privileges than root, as by default, Docker images are run by a root user (depending on the base image you are using). You can check this by running whoami in your Dockerfile.

FROM node:20.9.0-bullseye-slim
RUN echo "whoami: $(whoami)" # <--- whoami: root
Enter fullscreen mode Exit fullscreen mode

⚠️ If you are using docker with buildkit instead of the classic engine, pass this arg to the docker command to build: --progress=plain

docker build -t my-node-app --no-cache --progress=plain .
Enter fullscreen mode Exit fullscreen mode

In this base image from node and some others, including alpine base images, we have this node user that we can use instead of root, with fewer privileges. We should add this user to the image and use it instead of root, if possible.

You can find more info regarding the use of USER in a Dockerfile here.

FROM node:20.9.0-bullseye-slim
RUN echo "whoami: $(whoami)" # <--- whoami: root
USER node
RUN echo "whoami: $(whoami)" # <--- whoami: node
# Now we are using the 'node' user instead of root for the rest of the Dockerfile.
Enter fullscreen mode Exit fullscreen mode

The --chown=node:node option ensures that the ownership of the copied files is set to the node user and group.

FROM node:20.9.0-bullseye-slim
WORKDIR /app
USER node
COPY --chown=node:node ./src ./src
COPY --chown=node:node ./package*.json ./
Enter fullscreen mode Exit fullscreen mode

Keep in mind if you set the WORKDIR to /app, it means that the COPY directives, among other directives, will copy the files to the WORKDIR, which you can specify with .

So, it is the same to have:

FROM node:20.9.0-bullseye-slim
WORKDIR /app
COPY --chown=node:node ./src /app/src/
Enter fullscreen mode Exit fullscreen mode

than

FROM node:20.9.0-bullseye-slim
WORKDIR /app
COPY --chown=node:node ./src ./src/
Enter fullscreen mode Exit fullscreen mode

If you run

FROM node:20.9.0-bullseye-slim
WORKDIR /app
RUN echo "Working dir: $(pwd)" # <-- Working dir: /app
Enter fullscreen mode Exit fullscreen mode

You will see the output: Working dir: /app
Both approaches are perfectly valid.

3- Multi-stage builds.

Why? Security and size.

Size: does your final image need to have a package-lock.json, even a package.json ? Probably not, a NodeJS app needs the node_modules folder and the JS source code, usually in a JS project located in a src folder and if it is a TS project, in a dist folder where the compiled code from TS to JS is located.

If your NodeJS app is written in TypeScript, you might think:

FROM node:20.9.0-bullseye-slim
RUN apt-get update && apt-get install -y # <-- Maybe needed for your app
WORKDIR /app
COPY . /app
RUN npm ci --only=production
RUN npm run build # compile TS to JS
RUN rm -rf /app/src # <------- easy fix
CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode

Ok... what if to build or install the dependencies, you had to set up an NPM_TOKEN?

FROM node:20.9.0-bullseye-slim
RUN apt-get update && apt-get install -y # <-- Maybe needed for your app
ENV NPM_TOKEN abc1234_=xyz
WORKDIR /app
COPY . /app
RUN npm ci --only=production
RUN npm run build # compile TS to JS
RUN rm -rf /app/src # <------- easy fix
CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode

If you run docker history <name-of-the-image> you will be able to see the NPM_TOKEN. Of course, you can always run RUN export NPM_TOKEN=123 to remove it from the final image, but it is not the best approach and will still be visible in the docker history.

Also, you had to install some packages like gcc (apt-get update && apt-get install) to compile some NodeJS packages. Of course, you can always remove these packages like gcc but... Check the next approach.

FROM node:20.9.0-bullseye-slim as builder
RUN apt-get update && apt-get install -y # <-- Maybe needed for your app
ENV NPM_TOKEN abc1234_=xyz
WORKDIR /app
COPY . /app
RUN npm ci --only=production
RUN npm run build

# Here down, we are creating a new image from a clean 'node:20.9.0-bullseye-slim' and this will be the final image.
FROM node:20.9.0-bullseye-slim as prod
USER node
WORKDIR /app
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
COPY --chown=node:node --from=builder /app/dist ./dist
ENV production # <-- always good to have it
CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode

With this approach, you get a much cleaner Docker image without unnecessary stuff and any private ENV arg or similar in it.

Bonus tip ➡️

In Docker images and containers, you do not need to have the dotenv package, since Docker sets the ENV variables for you if you specify them when running the container via --env-file for example.

Conclusion

Writing Dockerfiles is easy, but it can get complicated quickly. These tips will help you write better Dockerfiles. They will make your containers more stable and secure. They will also make your containers smaller and faster.

If you want to learn more about Docker, check out the official Docker docs.

You can connect with me on Github or Linkedin.

Top comments (0)