Building REST API with Express, TypeScript - Part 2: Docker Setup

18 October 2020

In the previous post, we build a REST API server with Express and TypeScript. In this post, we will dockerize the sever.

Why Docker.

Docker helps organizations to ship and develop applications better and faster. It will be easy to set up the development environment on any new machine with docker as it abstracts out lots of complexity of setting up dependencies and environment. Docker also isolates the project from other projects in the same machine so the developer can run multiple projects without having any conflict with the required dependencies.

Docker makes it easy to configure and setup dependencies and environments for the application. As most of the companies have dedicated teams to do setup and manage infrastructure, Docker gives more power to developers to configure without depending on other teams to do the setup.

Write Dockerfile.

To Dockerize the server, we need to create a Dockerfile. A Dockerfile is just a list of instructions to create a docker image. Read more about Dockerfile here

Each line in the Dockerfile is a command and create a new image layer of its own. Docker caches the images during the build, so every rebuild will only create the new layer which got changed from the last build. Here the order of commands is very significant as it helps to reduce the build time.

Let's start writing Dockerfile for the server. Here we are taking node:12 as the base image for the server docker image. Explore dockerhub for more node image version. Here we are copying the package.json and doing npm install first, then copying the other files. Docker will cache the images of these two steps during the build and reuse them later as they change less frequently. Here we will be running the development server with the docker image, so we need to give npm run dev as the executing command.

Dockerfile

FROM node:12

WORKDIR /app

COPY package*.json ./

RUN npm install

COPY . .

EXPOSE 8000

CMD ["npm", "run", "dev"]

We need to add .dockerignore to tell docker build to ignore some files during the COPY Command.

.dockerignore

node_modules
npm-debug.log

After creating the Dockerfile, we need to run the docker build to create a docker image from the Dockerfile. Here we are naming the docker image as express-ts

docker build -t express-ts .

We can verify the docker image by running the docker images command. Here we can see the name, size, and tag of the docker images.

docker images
REPOSITORY                        TAG                 IMAGE ID            CREATED             SIZE
express-ts                        latest              d0ce1e38958b        2 minutes ago       1.11GB

We can run the docker image with the docker run command. Here we can mapping the system port 8000 to docker container port 8000. We can verify if the server is running or not by visiting http://localhost:8000/ping

docker run -p 8000:8000 express-ts

Add Docker Compose

The development server is running fine inside docker, but now we need to run the docker build command every time after making any changes to the source files to update the changes during development because the nodemon inside the docker container cannot watch the src folder on the local machine. We need to mount the local src folder to the docker container folder, so every time we make any change inside the src folder, nodemon restarts the development server inside the docker container.

We will add the docker-compose.yml file to the root of the project to mount the local src folder. Read more about docker-compose here

docker-compose.yml

version: "3"

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./src:/app/src
    ports:
      - "8000:8000"

We need to run the command docker-compose up to start the server. Now the server is running in development mode with auto-restart on code changes. We can verify the server is restarting on code changes by making any code change in the TypeScript files.

docker-compose up

The docker setup for the development server is completed. Let's rename the Dockerfile as Dockerfile.dev and update the docker-compose.yaml file. We will use the Dockerfile for the production image, which we are going to set up in the next section.

mv Dockerfile Dockerfile.dev

docker-compose.yml

version: "3"

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    volumes:
      - ./src:/app/src
    ports:
      - "8000:8000"

Add Production Dockerfile

Let's start building a docker image for the production server. We need to create a new Dockerfile and add the following commands. Here after copying the files, we need to build the JavaSript files and execute the npm start command.

FROM node:12

WORKDIR /app

COPY package*.json ./

RUN npm install

COPY . .

RUN npm run build

EXPOSE 8000

CMD ["node", "start"]

After running the docker build command, we can see the docker image is created for the production server.

docker images
REPOSITORY                      TAG                 IMAGE ID            CREATED             SIZE
express-ts                      latest              d0ce1e38958b        2 minutes ago       1.11GB

Here the image size is 1.11GB, which is not optimized. Let's optimize the docker image and reduce the size.

First, instead of taking node:12 as the base image, we will be taking its alpine variant. Alpine Linux is very lightweight. Read more about alpine-docker here.

FROM node:12-alpine

Let's build the docker image with the updated Dockerfile. Here we are tagging the docker image as alpine so we can compare the image size with the previous build.

docker build -t express-ts/alpine .
docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
express-ts                       alpine              2b06fcba880e        46 seconds ago      280MB
express-ts                       latest              d0ce1e38958b        2 minutes ago       1.11GB

After running the docker images command we can see the difference in the sizes of docker images. The docker image is much leaner than the previous build.

There are still some issues with our docker image as development dependencies are there in production build and TypeScript code is there, which is not required while running the server in production. So let's optimize the docker image further with a multi-stage build.

Here we create two stages, one for building the server and the other for running the server. In the builder stage, we generate Javascript code from the Typescript files. Then in the server stage, we copy the generated files from the builder stage to the server stage. In the Server stage, we need only production dependencies, that's why we will pass the --production flag to the npm install command.

FROM node:12-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM node:12-alpine AS server
WORKDIR /app
COPY package* ./
RUN npm install --production
COPY --from=builder ./app/public ./public
COPY --from=builder ./app/build ./build
EXPOSE 8000
CMD ["npm", "start"]

Let's build the docker image with the updated multi-staged Dockerfile. Here we are tagging the docker image as ms so we can compare the image sizes with the previous builds.

docker build -t express-ts/ms .
docker images

REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
express-ts                       alpine              2b06fcba880e        46 seconds ago      280MB
express-ts                       latest              d0ce1e38958b        2 minutes ago       1.11GB
express-ts                       ms                  26b67bfe45b0        9 minutes ago       194MB

After running the docker images command we can see the difference in the sizes of docker images. The multi staged image is the leanest among all images.

We have dockerized the development and production version of the Express and TypeScript REST API server.

All the source code for this tutorial is available on GitHub.

Next