Level up your NodeJS Dockerfiles with these 3 tips ⚡🐋
Tags: docker, node, typescript, javascript
Published: 2023-11-25 22:06:04

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:

```dockerfile
FROM node:lts-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "run", "start"]
```

## 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.

```dockerfile
FROM node:20.9.0-bullseye-slim
```

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.

```dockerfile
FROM node:20.9.0-bullseye-slim
RUN echo "whoami: $(whoami)" # <--- whoami: root
```
⚠️ If you are using docker with buildkit instead of the classic engine, pass this arg to the docker command to build: --progress=plain

```bash
docker build -t my-node-app --no-cache --progress=plain .
```

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.

```dockerfile
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.
```

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

```dockerfile
FROM node:20.9.0-bullseye-slim
WORKDIR /app
USER node
COPY --chown=node:node ./src ./src
COPY --chown=node:node ./package*.json ./
```

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:

```dockerfile
FROM node:20.9.0-bullseye-slim
WORKDIR /app
COPY --chown=node:node ./src /app/src/
```

than

```dockerfile
FROM node:20.9.0-bullseye-slim
WORKDIR /app
COPY --chown=node:node ./src ./src/
```

If you run

```dockerfile
FROM node:20.9.0-bullseye-slim
WORKDIR /app
RUN echo "Working dir: $(pwd)" # <-- Working dir: /app
```
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:

```dockerfile
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"]
```

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

```dockerfile
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"]
```

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.

```dockerfile
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"]
```

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](https://docs.docker.com/).

You can connect with me on [Github](https://github.com/aspnxdd) or [Linkedin](https://www.linkedin.com/in/arnau-espin-23789980/).