Part 7. Deploy Backend (NestJS): Docker/Docker-Compose

NestJS TypeScript NodeJS

This post is part of a Series of post which I'm describing a clock-in/out system if you want to read more you can read the following posts:


Introduction

When you develop a software application, you frequently code in a development environment. However, sooner or later, you will need to deploy your app in a production environment, while continuing to develop in your development environment

There are several solutions about the environment's variables management in node.js but the most popular library is dotenv (a simple tutorial can be read in twilio). In our case, we've develop our backend using the node.js framework NestJS which has a module to management the environment variables using dotenv (NestJS-Config). However, I've develop my own nestJS module to manage the NODE's environment variable without using external libraries.

Finally, our code is deployed using docker's containers, we will create an image from our code, and docker-compose.

Environment's variables

The first step is develop our EnvModule which load the custom variables from a file. So, it is very important known what's it environment's file which can be passed using the NODE_ENV (or any variable). The second step is to modify the DatabaseModule to load the information from the EnvModule. The NODE_ENV variable will be passed using docker-compose.

EnvModule

I've developed an EnvModule, which configures an environment variable, which will be either default or the NODE_ENVcontent. The next step is defining a provider, which uses a factory to return the env variable from the environment file. This provider is exported to be used in other modules.

import { Module, Provider } from '@nestjs/common';
import { ENV } from 'env/env';

const environment = process.env.NODE_ENV || 'default';

const provider: Provider = {
  provide: ENV,
  useFactory: () => import(`../../env/${environment}`).then(({ env }) => env),
};

@Module({
  providers: [provider],
  exports: [provider],
})
export class EnvModule {}

The interface that is used in the files is the one shown in the ```env/env.ts`` file . This configuration is about the database and its password. It is very important to make the PASSWORD in development and in production different, imagine everyone in the company knowing the database's root password because of such a mistake.

export interface ENV {
  DB_HOST: string;
  DB_PORT: number;
  DB_USERNAME: string;
  DB_PASSWORD: string;
  DB_DATABASE: string;
}

Therefore, the default environment will be the development environment, and production will be the production environment.

env/default.ts

import { ENV } from './env';

export const env: ENV = {
  DB_HOST: 'localhost',
  DB_PORT: 5531,
  DB_USERNAME: 'root',
  DB_PASSWORD: 'toor',
  DB_DATABASE: 'clock',
};

env/production.ts

import { ENV } from './env';

export const env: ENV = {
  DB_HOST: 'PostgreSQL',
  DB_PORT: 5432,
  DB_USERNAME: 'root',
  DB_PASSWORD: 'toor',
  DB_DATABASE: 'clock',
};

Note that the DB_HOST variable is the classic localhost in the default environment and, when the environment is set to production, its value is the name of the machine which contains the PostgreSQL database (this name is assigned by the container).

DatabaseModule

The EnvModule exports the ENV provider, which can be imported by DatabaseModule, to configure the databaseProvider. Therefore, the first modification is the DatabaseModule, which imports the module.

import { Module } from '@nestjs/common';
import { databaseProviders } from './database.providers';
import { EnvModule } from 'modules/env/env.module';

@Module({
  imports: [EnvModule],
  providers: [...databaseProviders],
  exports: [...databaseProviders],
})
export class DatabaseModule {}

Since EnvModule is exporting the provider, it can be injected in the DbConnectionToken provider, which receives the ENV as an argument. Instead of hard-coding the configuration in the provider, it is provided by the service (which is read from the environment file).

import { createConnection } from 'typeorm';
import { Provider } from '@nestjs/common';
import { ENV } from 'env';

export const databaseProviders: Provider[] = [
  {
    provide: 'DbConnectionToken',
    useFactory: async (env: ENV) => {
      return createConnection({
        type: 'postgres',
        host: env.DB_HOST,
        port: env.DB_PORT,
        username: env.DB_USERNAME,
        password: env.DB_PASSWORD,
        database: env.DB_DATABASE,
        entities: [__dirname + '/../**/*.entity.{ts,js}'],
        synchronize: true,
        logging: 'all',
      });
    },
    inject: [ENV],
  },
];

At this point, if you want to switch between environments, you can do so by running the following command:

NODE_ENV=production npm run start:watch

Deploy: Docker and Docker-compose

The idea is using the same environment in develop and production. In this context, Docker is the perfect tool due to it allowing us to configure different containers which switch the configuratino using our EnvModule. We need to build our own image, which will be a docker container, and after, this image will be orchestrated using Docker-compose.

Docker

Our dockerfile file is based on the node:10-alpine image due to the project does not need a system library. This image merely copies the source code and installs the dependencies (using npm install).

FROM node:10-alpine
WORKDIR /usr/src/app

COPY . .
RUN npm install


EXPOSE 3000
CMD [ "npm", "run", "start:prod" ]

When you build a docker image it is recommended to use a .dockerignore file, as you would use .gitignore.

node_modules
npm-debug.log
docker-volumes
e2e
test

Docker-compose

In our project, we have two different docker-compose files. The first is used for our development environment, since docker-compose only manages the DBMS Postgres due to the code being run on our platform by using this npm script: (npm run start:dev). Note that our service is based on postgres:10-alpine.

version: '2'
services:
  PostgreSQL:
    container_name: PostgreTestsClock
    image: postgres:10-alpine
    ports:
      - '5531:5432'
    environment:
      - POSTGRES_USER=root
      - POSTGRES_PASSWORD=toor
      - POSTGRES_DB=clock
    volumes:
      - ./docker-volumes/postgresql:/var/lib/postgresql

The second script is more complex, because in this case we have a container named clock-backend, based on the ccaballerog/clock-backend image, which was built in the last step. The clock-backend container is required to be aware of the PostgreSQL container. To do this, we could need a DNS server. However, docker-compose facilitates this task, by enablig the use of the networks keyword. Note that both containers have defined the same network (clock-frontend_clock-net).

The clock-backend container has an environment area, in which we've defined both the timezone and the NODE_ENV as production (to load our environment file).

version: '2'
services:
  PostgreSQL:
    container_name: PostgreTestsClock
    image: postgres:10-alpine
    ports:
      - '5531:5432'
    environment:
      - POSTGRES_USER=root
      - POSTGRES_PASSWORD=toor
      - POSTGRES_DB=clock
    volumes:
      - ./docker-volumes/postgresql:/var/lib/postgresql
    networks:
      - clock-frontend_clock-net
 
  clock-backend:
    image: ccaballerog/clock-backend
    container_name: ClockBackend
    restart: always
    environment:
      - NODE_ENV=production
      - SET_CONTAINER_TIMEZONE=true
      - CONTAINER_TIMEZONE=Europe/Madrid
      - TZ=Europe/Madrid
    ports:
      - '3000:3000'
    depends_on:
      - 'PostgreSQL'
    networks:
      - clock-frontend_clock-net
networks:
  clock-frontend_clock-net:
    external: true

Shell script to deploy

The last step of our process would be to automate the construction and execution of the containers. I have two scripts to do this task; the first script creates the image (removing the image, should there be one) and the second script deploys the code by using docker-compose.

#!/usr/bin/env bash
sh create-image.sh
docker-compose -f ../docker-compose.production.yml up --force-recreate
#!/usr/bin/env bash
docker rm -f clock-backend # remove the container
docker rmi -f ccaballerog/clock-backend  # remove the image
docker image prune # remove all images without use
docker volume prune # remove all volumes without use
docker build -t ccaballerog/clock-backend .. # create the image 

Conclusion

In this post I've explained how you can deploy your backend with NestJS by using docker and docker-compose. The most interesting feature of this code, is the fact that we can load our own environment variables, switching between development and production environments.