Go to content
Blog / Memo /

Notes on “Docker for Node.js Projects …”

Reference to the course: Docker for Node.js Projects from a Docker Captain

I will update this post while continuing advance through the course.
Last updated: 25 February 2023.

Docker Compose basics

Docker Compose is made of two things: the Compose CLI, and Compose file.

Docker Compose CLI is mostly a substitute for Docker CLI.
It is most useful in development environment.

Docker Compose file is usually named docker-compose.yml:

services:
    web:
        image: sample-01
        build: .
        ports:
            - "3000:3000"

A service in this context is a container based on a single image.

Tips

Get inside the container, and inspect things from inside it.

docker compose exec <service name> sh

Node Dockerfile best practice basics

  • Prefer COPY, over ADD. ADD is much more powerfull, but to put something inside our image COPY is usually enough.

  • npm config list && npm ci Print debug info about node/npm environment before installing node dependencies.

  • npm ci && npm cache clean --force Always clear cache after installing dependencies, in order to reduce image size.

  • CMD node, not npm.

  • Use WORKDIR, not RUN mkdir.

  • Add node_modules/.bin to PATH

# Dockerfile
ENV PATH=/path/to/node_modules/.bin:$PATH

Base Images

Alpine is small, and security focused. Debian/Ubuntu are small now too (~100MB space savings isn’t significant).

Non-root user

Official node image comes already with a predefined node user. Change user from root to node:

USER node

Multi-stage Dockerfile

It solves one, major complexity as your complexity grows in your Dockerfiles. That is, how do we take a single Dockerfile, with a single FROM, and single CMD. How do we use that in all the different places.

Lecture reference

FROM node:10-slim as prod
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm install --only=production \
    && npm cache clean --force
COPY . .
CMD ["node", "./bin/www"]

FROM prod as dev
ENV NODE_ENV=development
RUN npm install --only=development
CMD ["node_modules/nodemon/bin/nodemon.js",  "./bin/www", "--inspect=0.0.0.0:9229"]

FROM dev as test
ENV NODE_ENV=development
CMD ["npm", "test"]

Note: this example uses npm install, but it would be better to use npm ci (ref) that strictly respects the dependencies version listed in the lock file. npm ci uses the NODE_ENV variable to determine whether it should install dev dependencies.

In docker-compose.yml, use target to target a specific stage; eg.

services:
  my-service:
    build:
      context: .
      target: dev

    ...

A more complex example:

## Stage 1 (production base)
# This gets our prod dependencies installed and out of the way
FROM node:10-alpine as base

EXPOSE 3000

ENV NODE_ENV=production

WORKDIR /opt

COPY package*.json ./

# we use npm ci here so only the package-lock.json file is used
RUN npm config list \
    && npm ci \
    && npm cache clean --force


## Stage 2 (development)
# we don't COPY in this stage because for dev you'll bind-mount anyway
# this saves time when building locally for dev via docker-compose
FROM base as dev

ENV NODE_ENV=development

ENV PATH=/opt/node_modules/.bin:$PATH

WORKDIR /opt

RUN npm install --only=development

WORKDIR /opt/app

CMD ["nodemon", "./bin/www", "--inspect=0.0.0.0:9229"]


## Stage 3 (copy in source)
# This gets our source code into builder for use in next two stages
# It gets its own stage so we don't have to copy twice
# this stage starts from the first one and skips the last two
FROM base as source

WORKDIR /opt/app

COPY . .


## Stage 4 (testing)
# use this in automated CI
# it has prod and dev npm dependencies
# In 18.09 or older builder, this will always run
# In BuildKit, this will be skipped by default
FROM source as test

ENV NODE_ENV=development
ENV PATH=/opt/node_modules/.bin:$PATH

# this copies all dependencies (prod+dev)
COPY --from=dev /opt/node_modules /opt/node_modules

# run linters as part of build
# be sure they are installed with devDependencies
RUN eslint .

# run unit tests as part of build
RUN npm test

# run integration testing with docker-compose later
CMD ["npm", "run", "int-test"]


## Stage 5 (default, production)
# this will run by default if you don't include a target
# it has prod-only dependencies
# In BuildKit, this is skipped for dev and test stages
FROM source as prod

CMD ["node", "./bin/www"]

The Twelve-Factor App

Reference.

.dockerignore

Prevent bloat and unneeded files in the image.

.DS_Store
.git/
node_modules/
npm-debug
docker-compose*.yml

Docker Compose

Bind-Mounting Code

  • Don’t use host file paths
  • Don’t bind mount databases
# same as
# docker container run -p 80:4000 -v $(pwd):/site bretfisher/jekyll-serve

services:
  jekyll:
    image: bretfisher/jekyll-serve
  volumes:
    - .:/site
  ports:
    - "80:4000"

Run with: docker compose up.

Node Modules in Bind-Mounts

When we bind mount our source in Docker we end up with node_modules from host inside the container. This is not desiderable, especially on MacOS/Windows cause it’s a different architecture than Linux.

There are two ways to avoid this:

Solution 1

Never use npm i on host, run npm i in compose.

Then when there’s the bind mount to your host, it will then put those Node modules on the host OS.

# Run install command within the container
# 'express' is the service name from the compose file
docker compose run express npm install

This means that you can’t do Node development natively on your host anymore because those Node modules are designed for Linux.

Solution 2

Move node_modules in image, hide node_modules from host.

  • Dockerfile:
FROM node:10.15-slim

ENV NODE_ENV=production

WORKDIR /node

COPY package.json package-lock*.json ./

RUN npm install && npm cache clean --force

WORKDIR /node/app

COPY . .

CMD ["node", "./bin/www"]

node_modules will be installed in /node folder, the source will be in /node/app

  • docker-compose.yml
version: '2.4'

services:
  express:
    build: .
    ports:
      - 3000:3000
    volumes:
      - .:/node/app
      - /node/app/node_modules # make the node_modules directory empty
    environment:
      - DEBUG=sample-express:*

Run various tools inside the container

  • run
# Start a new container and run command/shell
docker compose run <service> bash
docker compose run <service> npm install
  • exec
# Run additional command/shell in a currently running container
docker compose exec <service> bash
docker compose exec <service> npm install

Node Auto Restarts

  • Override startup command from docker-compose.yml
  • Use nodemon
version: '2.4'

services:
  express:
    build: .
    command: /app/node_modules/.bin/nodemon ./bin/www
    ports:
      - 3000:3000
    volumes:
      - .:/node/app
      - /node/app/node_modules # make the node_modules directory empty
    environment:
      - DEBUG=sample-express:*
      - NODE_ENV=development

Startup Order and Dependencies

Multi-container apps need:

  • Dependency awareness, depends_on
  • Name resolution (DNS)
  • Connection failure handling, restart: on-failure

depends_on allow to have an healthcheck.

version: '2.4'

services:

  frontend:
    image: nginx
    depends_on:
      api:
        # this requires a compose file version => 2.3 and < 3.0,
        # and to define a healthcheck test for the `api` service
        condition: service_healthy

  api:
    image: node:alpine
    healthcheck:
      test: curl -f http://127.0.0.1