Host Nestjs Microservices on Coolify
If you’re building a microservice architecture using NestJS and gRPC, you might quickly find yourself managing multiple services that share the same node_modules, package.json, and even protobuf definitions. In this blog post, I’ll walk you through how I structured my monorepo, containerized it efficiently using a single Dockerfile, and handled environment-aware service resolution to avoid gRPC connection issues like ECONNREFUSED.
🏗️ The Architecture
I have a monorepo with the following structure:
.
├── apps
│ ├── api-gateway
│ │ └── main.ts
│ └── auth
│ └── main.ts
├── proto
│ └── auth.proto
├── package.json
├── tsconfig.json
├── docker-compose.yaml
└── Dockerfile
The api-gateway
communicates with the auth
service via gRPC using @nestjs/microservices
.
🐘 Single Dockerfile for All Services
Instead of maintaining separate Dockerfiles for each service, I created a single Dockerfile:
Dockerfile
# syntax=docker/dockerfile:1
FROM node:18-alpine
# Enable pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Set working directory
WORKDIR /app
# Install dependencies
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
# Copy all files
COPY . .
# Build all apps
RUN pnpm run build
✅ No CMD
here — we'll define it in Docker Compose per service.
📦 Docker Compose for Service Isolation
To run api-gateway
and auth
as separate containers but share the same Docker build context:
docker-compose.yaml
version: '3.9'
services:
auth:
build:
context: .
dockerfile: Dockerfile
command: pnpm start auth
ports:
- '5000:5000'
environment:
- NODE_ENV=production
api-gateway:
build:
context: .
dockerfile: Dockerfile
command: pnpm start api-gateway
ports:
- '3000:3000'
environment:
- NODE_ENV=production
Now to run them, use commands:
pnpm start api-gateway
pnpm start auth
⚙️ Dynamic gRPC URLs: Localhost vs Container DNS
The key issue I ran into was this:
- In local development,
api-gateway
connects tolocalhost:5000
. - But in Docker, it must connect to the Docker DNS name, i.e.,
auth:5000
.
Solution: Use process.env.NODE_ENV
Auth Microservice (gRPC Server)
apps/auth/main.ts
import { NestFactory } from '@nestjs/core';
import { AuthModule } from './auth.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { join } from 'path';
import { AUTH_PACKAGE_NAME } from '@app/common';
async function bootstrap() {
const isProd = process.env.NODE_ENV === 'production';
const host = isProd ? 'auth' : 'localhost';
const url = `${host}:5000`;
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
AuthModule,
{
transport: Transport.GRPC,
options: {
protoPath: join(__dirname, '../auth.proto'),
package: AUTH_PACKAGE_NAME,
url,
},
},
);
await app.listen();
}
bootstrap();
API Gateway (gRPC Client)
apps/api-gateway/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { AUTH_SERVICE } from './constants';
import { AUTH_PACKAGE_NAME } from '@app/common';
import { join } from 'path';
const isProd = process.env.NODE_ENV === 'production';
const url = isProd ? 'auth:5000' : 'localhost:5000';
@Module({
imports: [
ClientsModule.register([
{
name: AUTH_SERVICE,
transport: Transport.GRPC,
options: {
package: AUTH_PACKAGE_NAME,
protoPath: join(__dirname, '../auth.proto'),
url,
},
},
]),
],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
Now the gRPC connection works seamlessly in both environments.
✅ Summary
- 🐳 Single Dockerfile for full monorepo: faster builds, less duplication.
- ⚙️ Per-service command via
docker-compose.yaml
. - 🌐 Environment-based
url
switching avoids hardcodedlocalhost
issues. - 📦 One
node_modules
, one lockfile, one image.