Ready for Launch: How to Dockerize Your Entire NestJS Microservices App
Alright, future DevOps rockstars! This is it. We've built an incredible NestJS microservice ecosystem: our api-gateway routes requests, our users-service manages data with PostgreSQL and TypeORM, events fly asynchronously via RabbitMQ to our notifications-service, and the whole thing is secured with JWT authentication. That's a lot of moving parts, and you've done a phenomenal job assembling them!
But now comes the ultimate question: how do we take this beautiful, complex beast and make it run effortlessly, consistently, and reliably, whether it's on your local machine, a staging server, or in the cloud? Manually starting each service, setting environment variables, and ensuring they all connect correctly would be a nightmare.
Enter Docker and Docker Compose – your new best friends for packaging, deploying, and orchestrating distributed applications. This final part of our series will show you how to wrap up your entire microservice blueprint into portable containers, ready for launch!
Why Docker? The Power of Containerization
If you've heard the buzz about containers but aren't quite sure why they're so revolutionary, let's break it down. Docker provides a way to package your application and all its dependencies into a single, isolated unit called a container image. This image can then be run on any system that has Docker installed, guaranteeing that your application will behave exactly the same way, regardless of the underlying environment.
Think of it like this:
- Before Docker (The "It Works on My Machine!" Problem): You develop your app, it works perfectly on your laptop. You hand it over to a colleague, and suddenly it breaks. "Oh, you're on a different Node.js version!" or "You don't have that specific library installed globally!" Docker solves this by bundling everything your app needs.
- With Docker (The Portable Package): You package your NestJS service into a Docker image. This image contains your code, Node.js runtime, npm dependencies, and any other system-level libraries it needs. Now, anyone can run that image, and it will work the same way every single time, because its environment is self-contained.
Here are the key benefits of Docker for microservices:
- Consistency and Isolation: Each microservice runs in its own isolated container. This means they won't interfere with each other's dependencies, environment variables, or network ports. What works in development will work in production, reducing "works on my machine" issues.
- Portability: A Docker image is a standalone executable package. You can build it once and run it anywhere – on your laptop, a virtual machine, a cloud server, or even a Kubernetes cluster.
- Simplified Deployment: Instead of installing Node.js, npm, cloning repos, and running
npm installfor each service on every server, you just need Docker. Pull the image, run the container. Done. - Resource Efficiency: Containers are much lighter than traditional virtual machines. They share the host OS kernel, leading to faster startup times and less overhead.
- Scalability: With Docker, scaling a service is as simple as running more instances of its container. Orchestration tools (like Docker Compose or Kubernetes) can automate this process.
Dockerizing Individual NestJS Services
A Dockerfile is a text file that contains a series of instructions for building a Docker image. It's like a recipe for your application's environment.
Let's create a Dockerfile for each of our NestJS applications (api-gateway, users-service, notifications-service). Since they are all NestJS apps within an Nx monorepo, their Dockerfiles will be very similar.
Important Note for Nx Monorepos: When building Docker images for Nx projects, you typically want to build the entire monorepo first, and then copy only the necessary build artifacts into the final Docker image. This ensures all shared libraries are correctly bundled.
Generic NestJS Dockerfile Structure
Here's a common pattern for a NestJS Dockerfile:
# Stage 1: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package.json and install dependencies
# This step is optimized to leverage Docker cache.
# If package.json doesn't change, these layers are reused.
COPY package.json yarn.lock* package-lock.json* ./
RUN npm install --frozen-lockfile
# Copy the entire Nx monorepo source code
COPY . .
# Build the specific NestJS application using Nx
# Replace 'your-app-name' with api-gateway, users-service, etc.
RUN npx nx build your-app-name --prod
# Stage 2: Create the final production image
FROM node:20-alpine
WORKDIR /app
# Copy only the built application from the builder stage
# Replace 'your-app-name' with api-gateway, users-service, etc.
COPY --from=builder /app/dist/apps/your-app-name .
# Expose the port your application listens on
# Replace 3000 with the actual port (e.g., 3001 for users-service)
EXPOSE 3000
# Command to run the application
CMD ["node", "main.js"]Let's apply this to our services. Create these files in their respective apps/ directories:
1. apps/api-gateway/Dockerfile
# apps/api-gateway/Dockerfile
# Stage 1: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package.json and install dependencies
COPY package.json yarn.lock* package-lock.json* ./
RUN npm install --frozen-lockfile
# Copy the entire Nx monorepo source code
COPY . .
# Build the api-gateway application
RUN npx nx build api-gateway --prod
# Stage 2: Create the final production image
FROM node:20-alpine
WORKDIR /app
# Copy only the built api-gateway application from the builder stage
COPY --from=builder /app/dist/apps/api-gateway .
# Expose the port the API Gateway listens on (3000)
EXPOSE 3000
# Command to run the application
CMD ["node", "main.js"]2. apps/users-service/Dockerfile
# apps/users-service/Dockerfile
# Stage 1: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package.json and install dependencies
COPY package.json yarn.lock* package-lock.json* ./
RUN npm install --frozen-lockfile
# Copy the entire Nx monorepo source code
COPY . .
# Build the users-service application
RUN npx nx build users-service --prod
# Stage 2: Create the final production image
FROM node:20-alpine
WORKDIR /app
# Copy only the built users-service application from the builder stage
COPY --from=builder /app/dist/apps/users-service .
# Expose the port the Users Service listens on (3001)
EXPOSE 3001
# Command to run the application
CMD ["node", "main.js"]3. apps/notifications-service/Dockerfile
# apps/notifications-service/Dockerfile
# Stage 1: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package.json and install dependencies
COPY package.json yarn.lock* package-lock.json* ./
RUN npm install --frozen-lockfile
# Copy the entire Nx monorepo source code
COPY . .
# Build the notifications-service application
RUN npx nx build notifications-service --prod
# Stage 2: Create the final production image
FROM node:20-alpine
WORKDIR /app
# Copy only the built notifications-service application from the builder stage
COPY --from=builder /app/dist/apps/notifications-service .
# Expose the port (not strictly necessary for RMQ-only service, but good practice)
EXPOSE 3002 # Or any other unused port if it were to expose HTTP/gRPC
# Command to run the application
CMD ["node", "main.js"]Understanding the Dockerfile Stages (Multi-stage Builds):
FROM node:20-alpine AS builder: This is our "builder" stage. We start with a Node.js image (version 20, using the lightweight Alpine Linux distribution).WORKDIR /app: Sets the working directory inside the container.COPY package.json ...&RUN npm install: We copy only thepackage.json(and lock files) first and install dependencies. This is a Docker caching trick: if your dependencies don't change, Docker can reuse this layer, making subsequent builds much faster.COPY . .: Copies the entire monorepo source code into the builder container.RUN npx nx build your-app-name --prod: This is where Nx shines. We use thenx buildcommand to compile our specific NestJS application for production. The output goes intodist/apps/your-app-name.FROM node:20-alpine: This is our second stage, the "final" image. Notice we start fresh with another Node.js Alpine image. This is a best practice for production images:- Smaller Image Size: We only copy the compiled JavaScript output from the
builderstage, not the entire source code,node_modules, or build tools. This results in significantly smaller, more secure production images. - Reduced Attack Surface: Less unnecessary stuff in the final image means fewer potential vulnerabilities.
- Smaller Image Size: We only copy the compiled JavaScript output from the
COPY --from=builder /app/dist/apps/your-app-name .: This copies only the compiled output of our NestJS app from thebuilderstage into our lean final image.EXPOSE 3000: Informs Docker that the container listens on this port at runtime. It doesn't publish the port, just documents it.CMD ["node", "main.js"]: The default command that runs when the container starts. This executes our compiled NestJS application.
Orchestrating with Docker Compose
Now that we have Dockerfiles for our services, how do we make them all run together, along with PostgreSQL and RabbitMQ? That's where Docker Compose comes in.
Docker Compose is a tool for defining and running multi-container Docker applications. You define your entire application stack in a single YAML file (docker-compose.yml), and then with a single command, you can bring up (or tear down) all the services, networks, and volumes.
Creating docker-compose.yml
Create a file named docker-compose.yml in the root of your monorepo (nestjs-ms-blueprint/docker-compose.yml):
# docker-compose.yml
version: '3.8' # Specify the Docker Compose file format version
services:
# PostgreSQL Database Service
postgres:
image: postgres:16-alpine # Using a specific version and lightweight Alpine image
restart: always # Always restart if it crashes
environment:
POSTGRES_DB: postgres # Default database name
POSTGRES_USER: postgres # Default user
POSTGRES_PASSWORD: mysecretpassword # IMPORTANT: Match this with your TypeORM config!
ports:
- "5432:5432" # Map host port 5432 to container port 5432
volumes:
- postgres_data:/var/lib/postgresql/data # Persistent volume for database data
healthcheck: # Health check to ensure DB is ready before other services start
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
# RabbitMQ Message Broker Service
rabbitmq:
image: rabbitmq:3-management-alpine # Using a specific version with management UI
restart: always
ports:
- "5672:5672" # AMQP port for clients
- "15672:15672" # Management UI port
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
healthcheck: # Health check for RabbitMQ
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 5s
timeout: 5s
retries: 5
# Users Service (NestJS Microservice)
users-service:
build:
context: . # Build context is the monorepo root
dockerfile: apps/users-service/Dockerfile # Path to its Dockerfile
restart: always
environment:
# Ensure TypeORM connects to the 'postgres' service name within the Docker network
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: mysecretpassword
DATABASE_NAME: postgres
# Ensure RabbitMQ client connects to the 'rabbitmq' service name
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672
ports:
- "3001:3001" # Expose gRPC port for potential direct access (or for API Gateway)
depends_on: # Ensure postgres and rabbitmq are healthy before starting
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
# Notifications Service (NestJS Microservice)
notifications-service:
build:
context: .
dockerfile: apps/notifications-service/Dockerfile
restart: always
environment:
# Ensure RabbitMQ client connects to the 'rabbitmq' service name
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672
depends_on: # Ensure rabbitmq is healthy before starting
rabbitmq:
condition: service_healthy
# API Gateway (NestJS HTTP Server)
api-gateway:
build:
context: .
dockerfile: apps/api-gateway/Dockerfile
restart: always
environment:
# Ensure gRPC client connects to the 'users-service' service name
USERS_SERVICE_URL: 127.0.0.1:3001 # This is for the API Gateway's internal client config
# It connects to the users-service container's exposed port.
# Note: Using 'users-service:3001' would be more Docker-native
# but NestJS gRPC client options often expect IP:Port format.
# For simplicity, we'll keep 127.0.0.1:3001 as it's mapped.
# If users-service were on a different container, you'd use its service name.
JWT_SECRET: superSecretKeyThatShouldBeInEnvVariables # Match with AuthModule secret
ports:
- "3000:3000" # Map host port 3000 to container port 3000
depends_on: # Ensure users-service is healthy before starting
users-service:
condition: service_healthy
volumes:
postgres_data: # Define the named volume for PostgreSQL data persistenceKey Concepts in docker-compose.yml:
version: '3.8': Specifies the Docker Compose file format version.services:: Defines the individual containers that make up your application.image:: Forpostgresandrabbitmq, we use pre-built Docker Hub images.build:: For our NestJS services, we tell Docker Compose to build the image using aDockerfile.context: .: The build context is the current directory (the monorepo root), which is important because our Dockerfiles copy the entire monorepo.dockerfile: apps/your-service/Dockerfile: Specifies the path to theDockerfilefor that service.
restart: always: Ensures the container automatically restarts if it stops or crashes.environment:: Sets environment variables inside the container. This is crucial for configuring our services to talk to each other within the Docker network.- Inter-Container Communication: Notice how
users-serviceconnects toDATABASE_HOST: postgresandRABBITMQ_URL: amqp://guest:guest@rabbitmq:5672. Within a Docker Compose network, services can refer to each other by their service names (e.g.,postgres,rabbitmq). Docker's internal DNS handles the resolution. - Database/RabbitMQ Credentials: We pass the same credentials defined for the
postgresandrabbitmqservices. - JWT Secret: Passed to the
api-gateway.
- Inter-Container Communication: Notice how
ports:: Maps ports from your host machine to the container."5432:5432": Host port 5432 maps to container port 5432."3000:3000": Host port 3000 maps to container port 3000. This means you'll access your API Gateway viahttp://localhost:3000from your host machine.
volumes:: Used for data persistence.postgres_data:/var/lib/postgresql/data: This creates a named Docker volume calledpostgres_data. This volume will persist the PostgreSQL database files even if thepostgrescontainer is removed or recreated. This is crucial to avoid losing your data!
depends_on:: Defines dependencies between services. Docker Compose will start services in the order defined bydepends_on.condition: service_healthy: This is a powerful addition. Instead of just waiting for the container to start, it waits until the service passes itshealthcheck. This ensures thatusers-servicedoesn't try to connect topostgresuntilpostgresis actually ready to accept connections, preventing startup errors.
healthcheck:: Defines commands that Docker Compose can run periodically to check the health of a service. If the command exits with a non-zero status, the service is considered unhealthy.
Updating NestJS Config to Use Environment Variables
Our NestJS services are currently hardcoded with localhost and specific ports. We need to update them to read their connection details from environment variables, which Docker Compose will provide.
1. apps/users-service/src/main.ts (No change needed here for gRPC URL, it's defined in client config)
2. apps/users-service/src/app/app.module.ts
// apps/users-service/src/app/app.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { join } from 'path';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app/app.controller';
import { AppService } from './app.service';
import { User } from './user/entities/user.entity';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DATABASE_HOST || 'localhost', // Read from env var
port: parseInt(process.env.DATABASE_PORT || '5432', 10), // Read from env var
username: process.env.DATABASE_USERNAME || 'postgres', // Read from env var
password: process.env.DATABASE_PASSWORD || 'mysecretpassword', // Read from env var
database: process.env.DATABASE_NAME || 'postgres', // Read from env var
entities: [User],
synchronize: true,
}),
TypeOrmModule.forFeature([User]),
ClientsModule.register([
{
name: 'RABBITMQ_SERVICE',
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL || 'amqp://guest:guest@localhost:5672'], // Read from env var
queue: 'user_events_queue',
queueOptions: {
durable: false
},
},
},
]),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}3. apps/notifications-service/src/main.ts
// apps/notifications-service/src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app/app.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL || 'amqp://guest:guest@localhost:5672'], // Read from env var
queue: 'user_events_queue',
queueOptions: {
durable: false
},
},
});
await app.listen();
console.log(`Notifications Microservice (RabbitMQ) is listening for user_events_queue on ${process.env.RABBITMQ_URL || 'localhost'}`);
}
bootstrap();4. apps/api-gateway/src/app/app.module.ts
// apps/api-gateway/src/app/app.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [
AuthModule,
ClientsModule.register([
{
name: 'USERS_SERVICE',
transport: Transport.GRPC,
options: {
package: 'users',
protoPath: join(__dirname, '../..', 'libs/proto/users.proto'),
url: process.env.USERS_SERVICE_URL || '127.0.0.1:3001', // Read from env var
},
},
]),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}5. apps/api-gateway/src/app/auth/auth.module.ts
// apps/api-gateway/src/app/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { AuthController } from './auth.controller';
@Module({
imports: [
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'superSecretKeyThatShouldBeInEnvVariables', // Read from env var
signOptions: { expiresIn: '60s' },
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService, JwtModule],
})
export class AuthModule {}6. apps/api-gateway/src/app/auth/jwt.strategy.ts
// apps/api-gateway/src/app/auth/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { AuthService } from './auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'superSecretKeyThatShouldBeInEnvVariables', // Read from env var
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username, roles: payload.roles };
}
}Now, our NestJS applications are configured to dynamically pick up connection details from environment variables, making them truly portable within our Docker Compose setup.
Running Your Entire Microservices Stack with Docker Compose
Make sure you have Docker Desktop (or Docker Engine) installed and running.
- Stop any locally running services: If you still have
nx servecommands running from previous parts, stop them (Ctrl+C). Also, stop and remove any standalone Docker containers you ran previously (e.g.,docker stop some-postgres some-rabbitanddocker rm some-postgres some-rabbit). - Navigate to the monorepo root: Open your terminal and
cdintonestjs-ms-blueprint. Bring up the stack:
docker compose up --builddocker compose up: Starts all services defined indocker-compose.yml.--build: This is crucial! It tells Docker Compose to build the Docker images for our NestJS services (using their respective Dockerfiles) before starting the containers. If you've already built them and haven't changed the code, you can omit--buildfor faster restarts.
This command will:
- Pull the
postgresandrabbitmqimages (if not already present). - Build the Docker images for
api-gateway,users-service, andnotifications-service. - Start the
postgresandrabbitmqcontainers. - Wait for
postgresandrabbitmqto report as healthy. - Start
users-serviceandnotifications-service. - Wait for
users-serviceto report as healthy. - Start
api-gateway. - You'll see a lot of logs from all services streaming into your terminal!
Testing the Dockerized Application
Once all services are up and running (you'll see logs from all of them), you can test them just like before, but now everything is running inside Docker containers!
- Access API Gateway: Open Postman/Insomnia or your browser.
- Login:
POST http://localhost:3000/auth/loginwith{"username": "testuser", "password": "testpass"}to get a JWT. - Get Users:
GET http://localhost:3000/userswithAuthorization: Bearer YOUR_JWT_TOKEN. - Create User:
POST http://localhost:3000/userswithAuthorization: Bearer YOUR_JWT_TOKENand a user payload. - Verify Notifications: Check the logs in the terminal where
docker compose upis running; you should see thenotifications-serviceprocessing theuser_createdevent.
- Login:
Everything should work seamlessly, demonstrating the power of Docker Compose in orchestrating your entire microservice architecture.
Cleaning Up
When you're done, you can stop and remove all containers, networks, and volumes defined in your docker-compose.yml with a single command:
docker compose down -v
docker compose down: Stops and removes containers and default networks.-v: This is important! It also removes the named volumes (likepostgres_data), which means your database data will be deleted. If you want to keep your data betweendownandupcycles, omit-v.
Wrapping Up Part 7 and the Series!
Congratulations! You've reached the end of our NestJS Microservice Blueprint journey. In this final part, you've learned to Dockerize your entire application stack, from individual NestJS services to external dependencies like PostgreSQL and RabbitMQ, all orchestrated with a single docker-compose.yml file.
You now have a portable, consistent, and easily deployable microservice solution. This is a massive leap towards deploying your applications to production environments, whether that's a simple cloud VM or a sophisticated Kubernetes cluster.
Throughout this series, we've covered:
- The "Why" of microservices and the power of NestJS.
- Setting up a robust Nx monorepo.
- Building an API Gateway and your first microservice.
- Implementing high-performance gRPC for synchronous communication.
- Mastering event-driven patterns with RabbitMQ for asynchronous decoupling.
- Handling data persistence with TypeORM and PostgreSQL.
- Securing your API with JWT authentication.
- And finally, Dockerizing the entire stack for seamless deployment.
You've built a modern, scalable, resilient, and secure microservice application. This blueprint provides a solid foundation for building complex distributed systems. The world of microservices is vast, but you now have the essential tools and knowledge to explore it further.
What's next? You could explore:
- Implementing more advanced authorization (role-based access control, permissions).
- Adding more microservices for other domains (e.g.,
products-service,orders-service). - Implementing distributed tracing and logging for better observability.
- Exploring more advanced RabbitMQ exchange types and patterns.
- Deploying your Docker Compose application to a cloud provider (AWS ECS, Google Cloud Run, Azure Container Apps, DigitalOcean Droplets).
- Learning about Kubernetes for even more powerful container orchestration in production.
Keep building, keep learning, and keep pushing the boundaries of what you can create! Thanks for joining me on this journey. Happy coding!
