Building a Production - Ready Node.js App with TypeScript and Docker

One of the essential things that we miss while learning technology or programming language is to build it as production-ready. I realized it after learning Node.js and Typescript. This article shows you how you can build a Production-ready Node.js, Typescript Application with Docker.

If you are new to Typescript, I recommend you to watch this tutorial to learn the basics of TypeScript.

Application Setup

Firstly, you need to install Typescript on your machine. Run the following command, and this will install Typescript globally in your machine.

1npm install -g typescript

Create a directory and initialize the node.js Application with the command.

1npm init --yes

After that, you need to create a typescript configuration file, which compiles the Typescript into javascript.

1tsc --init

It will create a configuration file called tsconfig.json, which contains the Application's TypeScript configuration.

Configuring TypeScript

The configuration file will have default compiler options, and we can customize it based on our Application's requirement. important options are,

  • target - it can be either ES6 or ES5, based on the options, TypeScript compiles the code either to ES6 or ES5.
  • outDir - it specifies the directory where the compiled code will be stored.
  • rootDir - it specifies the directory on which the typescript code will be.
  • moduleResolution - specifies the module resolution strategy

config ts

Once you complete the configuration, we need to install few dependencies to set up and run the Typescript on the express Application.

Install the following dependencies using the command

1npm i -D typescript ts-node @types/node @types/express
  • ts-node - it is a package for using TypeScript with Node.js. we can run the application using ts-node app.ts
  • @types/node - it defines the custom types for Node.js in typescript
  • @types/express - it defines the custom types for express application in typescript

After that, create scripts in package.json to compile and run the application.

1"scripts": {
2 "dev": "ts-node src/app.ts",
3 "start": "ts-node dist/app.js",
4 "build": "tsc -p ."
5 }

Building Express Application with TypeScript

It's time to build the Express application using TypeScript.

Create a directory called src, which contains the TypeScript files and adds the app.ts file.

1import express, { Application, Request, Response, NextFunction } from "express"
2import bodyParser from "body-parser"
3
4const app: Application = express()
5
6app.use(bodyParser.json())
7app.use(bodyParser.urlencoded({ extended: true }))
8
9app.get("/", (req: Request, res: Response) => {
10 res.send("TS App is Running")
11})
12
13const PORT = process.env.PORT
14
15app.listen(PORT, () => {
16 console.log(`server is running on PORT ${PORT}`)
17})

One of the advantages of using TypeScript is defining the Type for the variable(Static Checking).

Here Express instance will be of Type Application; therefore, the variable must be of type Application. same goes for Request,Response and Next Function(Middleware).

After that, we need to write a logic to connect the database. create a file called connect.ts and add the following code,

1import mongoose from "mongoose"
2
3type DBInput = {
4 db: string,
5}
6
7export default ({ db }: DBInput) => {
8 const connect = () => {
9 mongoose
10 .connect(db, { useNewUrlParser: true })
11 .then(() => {
12 return console.info(`Successfully connected to ${db}`)
13 })
14 .catch(err => {
15 console.error(`Error connecting to database :`, err)
16
17 return process.exit(1)
18 })
19 }
20
21 connect()
22
23 mongoose.connection.on("disconnected", connect)
24}

DBInput is a type that takes variable db as a string. we use it to connect with mongodb.

After that, create directories Controllers,Models ,Routes and types in the root directory.

  • Controllers - contains all the business logic for the application
  • Models - contains all the Database Schema of Mongoose.
  • Routes - will have all the API Routes for the application
  • Types - will contain Custom types used in the Application

create a file User.mode.ts in Models Directory and add the following code,

1import mongoose, { Schema, Document } from "mongoose"
2
3export interface IUser extends Document {
4 email: String;
5 firstName: String;
6 lastName: String;
7}
8
9const UserSchema: Schema = new Schema({
10 email: {
11 type: String,
12 required: true,
13 unique: true,
14 },
15 firstName: {
16 type: String,
17 required: true,
18 },
19 lastName: {
20 type: String,
21 required: true,
22 },
23})
24
25export default mongoose.model < IUser > ("User", UserSchema)

Firstly, we define mongoose schema for user model and User Interface

In Controllers Directory, Create User.controller.ts file and add the following code.

1import User,{ IUser } from '../Models/User.model';
2
3interface ICreateUserInput {
4 email: IUser['email'];
5 firstName: IUser['firstName'];
6 lastName: IUser['lastName'];
7}
8
9async function CreateUser({
10 email,
11 firstName,
12 lastName
13 }: ICreateUserInput): Promise<IUser> {
14 return User.create({
15 email,
16 firstName,
17 lastName
18 })
19 .then((data: IUser) => {
20 return data;
21 })
22 .catch((error: Error) => {
23 throw error;
24 });
25 }
26
27 export default {
28 CreateUser
29 };

After that, create a file index.ts in Routes directory and add the following code,

1import { RoutesInput } from "../types/route"
2import UserController from "../Controllers/User.controller"
3
4export default ({ app }: RoutesInput) => {
5 app.post("api/user", async (req, res) => {
6 const user = await UserController.CreateUser({
7 firstName: req.body.firstName,
8 lastName: req.body.lastName,
9 email: req.body.email,
10 })
11
12 return res.send({ user })
13 })
14}

RoutesInput is a custom type that defines the Express Application Type.

create a file types.ts in types directory and add the code,

1import { Application } from "express"
2export type RoutesInput = {
3 app: Application,
4}

update the app.ts with mongodb connection and routes of the application.

1import express, { Application, Request, Response, NextFunction } from "express"
2import "dotenv/config"
3import bodyParser from "body-parser"
4import Routes from "./Routes"
5import Connect from "./connect"
6
7const app: Application = express()
8
9app.use(bodyParser.json())
10app.use(bodyParser.urlencoded({ extended: true }))
11
12app.get("/", (req: Request, res: Response) => {
13 res.send("TS App is Running")
14})
15
16const PORT = process.env.PORT
17const db = "mongodb://localhost:27017/test"
18
19Connect({ db })
20Routes({ app })
21
22app.listen(PORT, () => {
23 console.log(`server is running on PORT ${PORT}`)
24})

To test the application, run the script npm run dev and visit the URL http://localhost:4000

Docker Configuration

Docker comes in handy when you want to deploy your application without going through complicated server configurations.

If you are new to docker, read about docker for node.js and docker configuration.

Building Docker's image for our Application is simple and straightforward. But one thing to note here is to make it worthwhile for different environments. That's what we are going to learn,

Here, we will create a Docker setup for both development and production environment with best practices and guidelines.

Docker for Multiple Environments

Docker config

First of all, the Docker image is the base to wrap our Application into a container. For that, we need Dockerfile.

In most of the applications, there will be multiple docker containers that work together. To make it work, we need to create a service and network for them to communicate. that's called Docker compose.

So, we will create Dockerfile for production and development and use them in Docker compose.

Let's create Dockerfile.dev which is Dockerfile for development.

1FROM node:10
2
3WORKDIR /usr
4
5COPY package.json ./
6COPY tsconfig.json ./
7
8COPY src ./src
9RUN ls -a
10RUN npm install
11
12EXPOSE 4005
13
14CMD ["npm","run","dev"]

We take the node base image and install all our dependency in the docker image container.

After that, create Dockerfile for the production environment in the root directory and add the following code.

1FROM node:12.17.0-alpine
2WORKDIR /usr
3COPY package.json ./
4COPY tsconfig.json ./
5COPY src ./src
6RUN ls -a
7RUN npm install
8RUN npm run build
9
10## this is stage two , where the app actually runs
11FROM node:12.17.0-alpine
12WORKDIR /usr
13COPY package.json ./
14RUN npm install --only=production
15COPY --from=0 /usr/dist .
16RUN npm install pm2 -g
17EXPOSE 80
18CMD ["pm2-runtime","app.js"]

You can see the difference of the npm install command between the two configs. In development, we install devDependencies, whereas in production, we remove it by adding --production.

Here we follow multi-stage docker build. First, it compiles our image with a temporary docker image and copies that build to the final image.

After that, we install a process manager called pm2, mostly used in all production applications.

Docker Compose Configuration

Here, we are going to follow Docker compose composition. it separates the common configuration as the base configuration. On top of that, we can extend configuration based on the environment.

Let's create a base config file docker-compose.yml in the root directory and add the following code.

1version: "3.7"
2
3services:
4 mongo:
5 container_name: mongo
6 image: mongo
7 app:
8 container_name: app
9 external_links:
10 - mongo
11 depends_on:
12 - mongo

Docker compose combine multiple docker services and run it in a single container. Here, we combine MongoDB and Application images and run it container.

After that, create docker-compose.override.yml and add the following code,

1version: "3.7"
2
3services:
4 mongo:
5 container_name: mongo
6 image: mongo
7 restart: always
8 volumes:
9 - ./data:/data/db
10 ports:
11 - 27017:27017
12 app:
13 container_name: app
14 restart: always
15 build:
16 context: .
17 dockerfile: Dockerfile.dev
18 env_file: .env.local
19 environment:
20 - PORT=${PORT}
21 - MONGO_URL=${MONGO_URL}
22 ports:
23 - 4005:4005
24 external_links:
25 - mongo
26 depends_on:
27 - mongo
28volumes:
29 mongo-data:
30 driver: local

You can see that we use Dockerfile.dev since it's for the development environment. Also, we mount the database volume with our local machine.

You can either create multiple .env files use one. Here, I am using env for local and production.

Now, let's create Docker compose for production. docker-compose.prod.yml

1version: "3.7"
2
3services:
4 mongo:
5 container_name: mongo
6 image: mongo
7 restart: always
8 ports:
9 - 27017:27017
10 app:
11 container_name: app
12 restart: always
13 build: .
14 env_file: .env
15 environment:
16 - PORT=${PORT}
17 - MONGO_URL=${MONGO_URL}
18 ports:
19 - 4000:80
20 external_links:
21 - mongo
22 depends_on:
23 - mongo

Some of the best practices to use in production build are,

  • Remove any volume bindings in the application code. In that way, any outside environment can change the application code in production
  • It's better to bind the default port(port 80) in the production
  • Specify restart:always in config. So, it updates the build every time we deploy
  • Logs are important in the production

Docker Deployment and Running

Once, You add Dockerfile. Run the following command,

1docker-compose up

It will deploy the compiled code in the docker image and run it in the container.

Complete Source code contains Building a Production - Ready Node.js App with TypeScript and Docker.

Recommended Course(Affiliated)

To Read More

Modern React Redux Toolkit - Login ...

User Authentication is one of the common workflow in web applications. In this t...

Building Nodejs Microservice - A Cl...

This Article explains everything about how to build Nodejs Microservices in clou...

I Accidentally wiped the entire dat...

One of the tragic accident in my job turned out to be good learning for me in re...