Docker Container Roles Pattern for NestJS Apps
In this story, I will show you how I build small-to-medium-sized NestJS applications using a hybrid monolith pattern.
I have one repository, and one docker container with the whole app, but each container is a separate application, almost like it was deployed from another repo!
This is achieved by container role pattern and NestJS’s fantastic module architecture design.
Objective
Launch event-driven monolith NestJS application using just a single container image and repository.
My application will have an API route for math operation add
and will consume two arguments A
and B
.
Math add
operation and its arguments will be queued and processed by a background worker node. The result will be displayed in the instance log (real-world — database, external service, WebSocket broadcast — whatever).
This application will have one cron job
that runs minutely and logs its result in the worker’s console.
Solution
We’ll build a container that can run our NestJS app and its dependencies, but it will have a different module entry point based on the container’s role in the stack.
Our example app will have those roles:
- API service to ingest data
- Service to manage queues
- Service to run cron jobs
Our goal is to build this kind of architecture but with only 1 NestJS application.
Example application
Demo code for this application: https://github.com/dkhorev/docker-container-roles-pattern-nestjs
This is how the project folder would look like
common
— module that will provide storage connections and Models for all our sub-modules.
api
— a module that provides an API route for data ingestion and queues the jobs
workers
— a module that is responsible for processing queued jobs
cron
— a module that will run timed tasks for us and also has access to start queued jobs (for workers
to pick up)
Next, let’s look over the most interesting connecting parts from our modules.
API module
API controller receives A and B and adds a job to the math-add
queue.
Cron module
My sample cron service only has 1 schedule: add a queued job to the cron-jobs
queue every minute. We just pass a random string and will confirm it is received on the worker’s instance.
Workers module
This module has 2 queue processors
As you can see this setup is pretty simple, nonetheless, it has integrated Redis and Node.js Bull package to work via queues messaging. A good starting point for any similar async application.
Now let’s create our container.
Building the container
Nothing new here, it’s a basic multi-stage Node.js container.
Container entry point
This is the part where our app decides what role it is started with, and what modules should it load.
This file includes a map of roles with corresponding bootstrap functions returned.
api
— starts the application with ApiModule
, Fastify
adapter, and listens on a PORT
cron
— starts a standalone application with CronModule
, not listening for requests
workers
— again, just a standalone application with WorkersModule
Demo docker-compose file
This is what our service map looks like with the docker-compose file.
Notice every service that has image: role-app:latest
has a CONTAINER_ROLE
set.
API and Workers services are replicated to run 2 instances each to simulate scaled deployment.
Running the demo
I recommend building and pulling the required images first.
We can start our stack by running this command:
docker-compose up -d
We can trigger our API with simple curl
requests via console:
curl http://127.0.0.1:8181/api/add/1/10
curl http://127.0.0.1:8181/api/add/2/3
Now check workers logs with docker logs docker-container-roles-pattern-nestjs_workers_1
or docker logs docker-container-roles-pattern-nestjs_workers_2
. Jobs are distributed between them so your results may vary.
As you can see our workers app has already processed a few scheduler jobs and API requests.
Check scheduler logs: docker logs docker-container-roles-pattern-nestjs_cron_1
Now check what the app’s API instance is doing: docker logs docker-container-roles-pattern-nestjs_api_1
Conclusion
We have seen from the screenshots above — our monolith NestJS application can be launched with separate scalable services by just passing CONTAINER_ROLE
to each of them.
If you have a strong team, that can extend this project in a modular way (thanks NestJS!), then I found this approach can be effective.
You get a taste of building microservice-like architecture without the pitfalls it has for inexperienced engineering teams.
Effectively you have a set of microservices running from a single monolith repo:
- API layer
- Workers pool
- Cron scheduler
Possible to add with the same approach:
- Frontend layer (nginx + any SPA)
- DB migrator
- Any container to run custom commands/scripts
- Provide secret keys only to containers that need them
Demo code for this application: https://github.com/dkhorev/docker-container-roles-pattern-nestjs
If you’re interested in PHP, then check this article that has a demo of the same pattern applied with Laravel example: https://medium.com/@dkhorev/docker-container-roles-pattern-for-laravel-apps-d445a62d230f