Introduction

I built a Nodejs project over the weekend. Just thought I’d share and guide others with the design and build process of a scalable backend service with Node.js, TypeScript, Nginx and Docker without using any existing Nodejs frameworks. I used the find-my-way router to implement a REST API. And for the database, I’m using PostgreSQL because it’s reliable and scalable relational database. The aim was to build a backend service that can handle thousands of requests per second without any performance degradation. In other words, a highly available Nodejs service with minimal dependencies. Because we all know how fast you can spiral into a dependency hell when using npm packages.

What does the service do?

It’s basically a backend CRUD API for managing recipes (lol? who cares about recipes, we just want to build cool stuff).

Tech Stack: Docker, Nginx, Node.js, TypeScript, and PostgreSQL.

Repository

You can checkout the code here:

https://github.com/terminalbytes/nodejs-nginx

Setup

Clone the repository and run

docker-compose --env-file .env up --build

Depending on your setup, it could be either docker-compose or docker compose (ships with docker desktop).

Wait for the command to setup all the containers and Open Swagger UI running on the port 8082, i.e go to localhost:8082 or directly access the API on port 8084.

Building the Backend Service

Clone the repository and run

cd app/
yarn install
yarn build
yarn start

# OR you could use the dev command if you want to rn the app in development mode
# For this to work, you'd need a running PostgreSQL database running on your local
# machine on port 5432, check .env file for modifying the connection credentials.
yarn dev

Design

So what do you really need for a web service with nodejs? The following things:

  • Request handling / routing (link)
  • Body parsing (link)
  • Some sort of authentication handling before the request is handled (link)
  • Maybe some content-negotiation for the response (link)

For request handling, I used the awesome find-my-way router. It’s a crazy fast router that can handle thousands of requests per second. But it comes with a few limitations, it’s a bare router, i.e things like body parsing, middleware support that we’re so used to with Express is not available. So I had to implement my own middleware to handle these requirements.

I also wanted to abstract out all the database stuff, so I used Sequelize. It’s a nice ORM for Nodejs. It provides a nice abstraction layer for dealing with databases. All you have to do is interact with the Model classes and it will take care of the rest.

NPM Dependencies:

So let’s take a look at the dependencies we’ll need to build our backend service. Trust me this is a very compact list, we could’ve used a lot of npm packages, but this is the bare minimum we need to build a backend service (well not really, this is the bare minimum you’d need to build a service in 2 days).

  • dotenv: For environment variables
  • find-my-way: A crazy fast router, you probably don’t want to write your own router, since it’ll probably be not production ready and will have a lots of security issues.
  • jsonwebtoken: For signing and verifying tokens.
  • module-alias: For aliasing modules, i.e using @utils/ instead of ../../utils/ in imports.
  • pg: PostgreSQL driver for Nodejs.
  • pg-hstore: PostgreSQL hstore extension for Nodejs.
  • pino: A logger for Nodejs.
  • reflect-metadata: A metadata system for Nodejs.
  • sequelize: ORM for managing PostgreSQL so that we don’t have to write SQL queries.
  • sequelize-typescript: TypeScript support for Sequelize.
  • typescript: TypeScript transpiler.
  • xml-js: For converting JSON -> XML for content negotiation.

Features

  • Simple to setup.
  • Typescript / Strongly typed with Eslint.
  • Fully dockerized and with some CICD support using Github Actions
  • Production ready, out of the box rate limited APIs to prevent abuse
  • Unit tested
  • Paginated APIs
  • Built without using any web/micro frameworks
  • Secure access patterns and jwt auth
  • Relational database model using PostgreSql
  • Database abstraction using sequelize orm
  • Content Negotiation based on Accept request header.
  • Well documented APIs (Swagger, available at localhost:8082)
  • JSON logging which can be directly shipped to ES or Cloudwatch (when running on ECS).

Architecture

Live Traffic --> nginx --> Nodejs --> Sequelize --> Postgresql

  1. Live traffic is served by the nginx container, which acts as a rate limiter, load balancer and reverse proxy for the node containers (only 1 at the moment).
  2. node container talks to the database and serves the user request.

Content Negotiation

The API supports content negotiation, i.e it’ll automatically serve the response in the desired format. Just include the Accept request header with the content-type you want in the response.

Supported content types:

application/json  (<- default)
application/xml
text/plain

Tests

After you’ve cloned the repository, cd into the app/ directory and run the command:

yarn install; yarn test

The test file locations should mirror the targeted files in the app/src/ directory.

Design Decisions

Why nginx?

We want a high availability API. Nodejs is powerful but when it comes to multiple concurrent connections it performs badly because of the single threaded nature of its event loop. Also, rate limit logic in a Nodejs app is not at all a good idea (A DOS attack can crash a Nodejs service really easily, block the event loop for other users). Which is where nginx comes in, nginx is built for handling and rejecting requests at scale. So erroneous requests don’t even make it to our backend service, nginx filters them.

Why find-my-way?

Well, since the requirements stated that we cannot use a web/micro framework, I only had two options, either write my own router or use something like find-my-way/express-router.

Another major requirement was that I had to apply best security practices in this project. So writing my own router which conforms to the RFC3986 would probably take me a year. I didn’t want to do a poor job using switch(case) for routing, so used an existing library which provides URL sanitization and other sick features out of the box.

Also, I didn’t use express-router because it provides a lot of functionality out of the box so it felt a little cheaty to me xD, find-my-way is the bare minimum (and it’s crazy fast too).

Enough talk, show me the results!

Here are the results from load testing the endpoint (with 100 recipes fetched every time):

https://example-domain.com/api/recipes?limit=100

Clients: 10,000 / min

Average Response Time: 39 ms

Bandwidth: 974 MB

Load Test Result

Load testing the service 10,000 clients / minute
10,000 clients / minute

Server Performance during load testing

Nodejs Docker Server Performance when served with nginx reverse proxy
CPU: 4%; Mem: 1%

Hope this article helped you out, see you again soon!