Back to blog

Microservices Architecture with Node.js and Docker

9 min readBy Mustafa Akkaya
#Microservices#Node.js#Docker#Kubernetes#DevOps

Microservices architecture has become the standard for building scalable, maintainable applications. Let's explore how to design and implement a production-ready microservices system using Node.js and Docker.

Why Microservices?

Microservices offer significant advantages over monolithic architecture:

- Scalability - Scale services independently based on demand

- Flexibility - Use different technologies for different services

- Resilience - Failure in one service doesn't crash the entire system

- Faster Development - Teams can work on services independently

- Easy Deployment - Deploy and update services without downtime

- Better Organization - Clear boundaries and responsibilities

Architecture Design Principles

Single Responsibility

Each service should do one thing well.

script.js

class="keyword">class="comment">// ❌ Bad: Auth service doing too much

class="keyword">class AuthService {

class="keyword">class="keyword">async login(credentials) {}

class="keyword">class="keyword">async register(userData) {}

class="keyword">class="keyword">async sendEmail(to, subject, body) {}

class="keyword">class="keyword">async processPayment(amount) {}

class="keyword">class="keyword">async generateReport() {}

}

class="keyword">class="comment">// ✅ Good: Separate services

class="keyword">class="comment">// auth-service/

class="keyword">class AuthService {

class="keyword">class="keyword">async login(credentials) {}

class="keyword">class="keyword">async register(userData) {}

class="keyword">class="keyword">async refreshToken(token) {}

}

class="keyword">class="comment">// email-service/

class="keyword">class EmailService {

class="keyword">class="keyword">async sendEmail(to, subject, body) {}

class="keyword">class="keyword">async sendTemplateEmail(to, template, data) {}

}

class="keyword">class="comment">// payment-service/

class="keyword">class PaymentService {

class="keyword">class="keyword">async processPayment(amount, method) {}

class="keyword">class="keyword">async refund(transactionId) {}

}

Service Communication

Choose the right communication pattern.

Synchronous (REST/gRPC)

script.js

class="keyword">class="comment">// user-service/routes/users.js

class="keyword">import express class="keyword">from class="keyword">class="string">"express";

class="keyword">import axios class="keyword">from class="keyword">class="string">"axios";

class="keyword">class="keyword">const router = express.Router();

router.get(class="keyword">class="string">"/:id", class="keyword">class="keyword">async (req, res) => {

class="keyword">try {

class="keyword">class="keyword">const user = class="keyword">class="keyword">await User.findById(req.params.id);

class="keyword">class="comment">// Call order service to get user's orders

class="keyword">class="keyword">const ordersResponse = class="keyword">class="keyword">await axios.get(

class="keyword">class="string">http:class="keyword">class="comment">//order-service:3001/orders/user/${user.id},

{

timeout: 5000,

headers: {

Authorization: req.headers.authorization,

},

}

);

res.json({

...user.toJSON(),

orders: ordersResponse.data,

});

} class="keyword">catch (error) {

class="keyword">class="keyword">if (error.code === class="keyword">class="string">"ECONNREFUSED") {

class="keyword">class="comment">// Service unavailable - class="keyword">class="keyword">return partial data

res.json({ ...user.toJSON(), orders: [] });

} class="keyword">class="keyword">else {

res.status(500).json({ error: error.message });

}

}

});

class="keyword">export class="keyword">default router;

Asynchronous (Message Queue)

script.js

class="keyword">class="comment">// order-service/events/publisher.js

class="keyword">import amqp class="keyword">from class="keyword">class="string">'amqplib'

class="keyword">class OrderEventPublisher {

constructor() {

class="keyword">this.connection = class="keyword">null

class="keyword">this.channel = class="keyword">null

}

class="keyword">class="keyword">async connect() {

class="keyword">this.connection = class="keyword">class="keyword">await amqp.connect(process.env.RABBITMQ_URL)

class="keyword">this.channel = class="keyword">class="keyword">await class="keyword">this.connection.createChannel()

class="keyword">class="keyword">await class="keyword">this.channel.assertExchange(class="keyword">class="string">'orders', class="keyword">class="string">'topic', { durable: true })

}

class="keyword">class="keyword">async publishOrderCreated(order) {

class="keyword">class="keyword">const message = {

eventType: class="keyword">class="string">'ORDER_CREATED',

timestamp: class="keyword">new Date().toISOString(),

data: {

orderId: order.id,

userId: order.userId,

items: order.items,

total: order.total

}

}

class="keyword">this.channel.publish(

class="keyword">class="string">'orders',

class="keyword">class="string">'order.created',

Buffer.class="keyword">from(JSON.stringify(message)),

{ persistent: true }

)

console.log(class="keyword">class="string">'Published ORDER_CREATED event:', order.id)

}

}

class="keyword">export class="keyword">default class="keyword">new OrderEventPublisher()

class="keyword">class="comment">// inventory-service/events/consumer.js

class="keyword">import amqp class="keyword">from class="keyword">class="string">'amqplib'

class="keyword">import { reserveStock } class="keyword">from class="keyword">class="string">'../services/inventory'

class="keyword">class OrderEventConsumer {

class="keyword">class="keyword">async connect() {

class="keyword">class="keyword">const connection = class="keyword">class="keyword">await amqp.connect(process.env.RABBITMQ_URL)

class="keyword">class="keyword">const channel = class="keyword">class="keyword">await connection.createChannel()

class="keyword">class="keyword">await channel.assertExchange(class="keyword">class="string">'orders', class="keyword">class="string">'topic', { durable: true })

class="keyword">class="keyword">const queue = class="keyword">class="keyword">await channel.assertQueue(class="keyword">class="string">'inventory-order-events', {

durable: true

})

class="keyword">class="keyword">await channel.bindQueue(queue.queue, class="keyword">class="string">'orders', class="keyword">class="string">'order.created')

channel.consume(queue.queue, class="keyword">class="keyword">async (msg) => {

class="keyword">class="keyword">if (msg) {

class="keyword">class="keyword">const event = JSON.parse(msg.content.toString())

class="keyword">try {

class="keyword">class="keyword">await class="keyword">this.handleOrderCreated(event.data)

channel.ack(msg)

} class="keyword">catch (error) {

console.error(class="keyword">class="string">'Error processing event:', error)

class="keyword">class="comment">// Retry logic or dead letter queue

channel.nack(msg, false, true)

}

}

})

}

class="keyword">class="keyword">async handleOrderCreated(orderData) {

console.log(class="keyword">class="string">'Processing order:', orderData.orderId)

class="keyword">class="keyword">for (class="keyword">class="keyword">const item of orderData.items) {

class="keyword">class="keyword">await reserveStock(item.productId, item.quantity)

}

}

}

class="keyword">export class="keyword">default class="keyword">new OrderEventConsumer()

Service Structure

Standard microservice project structure:

file.txt

user-service/

├── src/

│ ├── config/

│ │ └── database.js

│ ├── models/

│ │ └── User.js

│ ├── routes/

│ │ └── users.js

│ ├── services/

│ │ └── userService.js

│ ├── middleware/

│ │ ├── auth.js

│ │ └── errorHandler.js

│ ├── events/

│ │ ├── publisher.js

│ │ └── consumer.js

│ └── app.js

├── tests/

│ ├── unit/

│ └── integration/

├── Dockerfile

├── docker-compose.yml

├── package.json

└── .env.example

Docker Containerization

Service Dockerfile

Dockerfile

# user-service/Dockerfile

class="keyword">FROM node:20-alpine class="keyword">AS builder

WORKDIR /app

# Copy package files

COPY package*.json ./

# Install dependencies

RUN npm ci --only=production

# Copy source code

COPY . .

# Production stage

class="keyword">FROM node:20-alpine

WORKDIR /app

# Create non-root user

RUN addgroup -g 1001 -S nodejs && \

adduser -S nodejs -u 1001

# Copy class="keyword">from builder

COPY --class="keyword">from=builder --chown=nodejs:nodejs /app /app

# Switch to non-root user

USER nodejs

# Expose port

EXPOSE 3000

# Health check

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \

CMD node -e class="keyword">class="string">"require('http').get('http:class="keyword">class="comment">//localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"

# Start application

CMD [class="keyword">class="string">"node", class="keyword">class="string">"src/app.js"]

Docker Compose for Development

config.yaml

# docker-compose.yml

version: class="keyword">class="string">"3.8"

services:

# API Gateway

api-gateway:

build:

context: ./api-gateway

dockerfile: Dockerfile

ports:

- class="keyword">class="string">"3000:3000"

environment:

- NODE_ENV=development

- USER_SERVICE_URL=http:class="keyword">class="comment">//user-service:3001

- ORDER_SERVICE_URL=http:class="keyword">class="comment">//order-service:3002

- PRODUCT_SERVICE_URL=http:class="keyword">class="comment">//product-service:3003

depends_on:

- user-service

- order-service

- product-service

networks:

- microservices

# User Service

user-service:

build:

context: ./user-service

dockerfile: Dockerfile

environment:

- NODE_ENV=development

- DATABASE_URL=postgresql:class="keyword">class="comment">//postgres:password@user-db:5432/users

- RABBITMQ_URL=amqp:class="keyword">class="comment">//rabbitmq:5672

- JWT_SECRET=${JWT_SECRET}

depends_on:

- user-db

- rabbitmq

networks:

- microservices

user-db:

image: postgres:16-alpine

environment:

- POSTGRES_DB=users

- POSTGRES_PASSWORD=password

volumes:

- user-data:/class="keyword">class="keyword">var/lib/postgresql/data

networks:

- microservices

# Order Service

order-service:

build:

context: ./order-service

dockerfile: Dockerfile

environment:

- NODE_ENV=development

- DATABASE_URL=postgresql:class="keyword">class="comment">//postgres:password@order-db:5432/orders

- RABBITMQ_URL=amqp:class="keyword">class="comment">//rabbitmq:5672

depends_on:

- order-db

- rabbitmq

networks:

- microservices

order-db:

image: postgres:16-alpine

environment:

- POSTGRES_DB=orders

- POSTGRES_PASSWORD=password

volumes:

- order-data:/class="keyword">class="keyword">var/lib/postgresql/data

networks:

- microservices

# Product Service

product-service:

build:

context: ./product-service

dockerfile: Dockerfile

environment:

- NODE_ENV=development

- MONGODB_URL=mongodb:class="keyword">class="comment">//product-db:27017/products

- REDIS_URL=redis:class="keyword">class="comment">//redis:6379

depends_on:

- product-db

- redis

networks:

- microservices

product-db:

image: mongo:7

volumes:

- product-data:/data/db

networks:

- microservices

# Message Broker

rabbitmq:

image: rabbitmq:3-management-alpine

ports:

- class="keyword">class="string">"5672:5672"

- class="keyword">class="string">"15672:15672"

environment:

- RABBITMQ_DEFAULT_USER=admin

- RABBITMQ_DEFAULT_PASS=admin

volumes:

- rabbitmq-data:/class="keyword">class="keyword">var/lib/rabbitmq

networks:

- microservices

# Cache

redis:

image: redis:7-alpine

ports:

- class="keyword">class="string">"6379:6379"

volumes:

- redis-data:/data

networks:

- microservices

volumes:

user-data:

order-data:

product-data:

rabbitmq-data:

redis-data:

networks:

microservices:

driver: bridge

API Gateway Pattern

Centralize routing and cross-cutting concerns.

script.js

class="keyword">class="comment">// api-gateway/src/app.js

class="keyword">import express class="keyword">from class="keyword">class="string">"express";

class="keyword">import { createProxyMiddleware } class="keyword">from class="keyword">class="string">"http-proxy-middleware";

class="keyword">import rateLimit class="keyword">from class="keyword">class="string">"express-rate-limit";

class="keyword">import helmet class="keyword">from class="keyword">class="string">"helmet";

class="keyword">import cors class="keyword">from class="keyword">class="string">"cors";

class="keyword">import jwt class="keyword">from class="keyword">class="string">"jsonwebtoken";

class="keyword">class="keyword">const app = express();

class="keyword">class="comment">// Security

app.use(helmet());

app.use(cors());

class="keyword">class="comment">// Rate limiting

class="keyword">class="keyword">const limiter = rateLimit({

windowMs: 15 * 60 * 1000,

max: 100,

});

app.use(limiter);

class="keyword">class="comment">// Authentication middleware

class="keyword">class="keyword">const authenticate = (req, res, next) => {

class="keyword">class="keyword">const token = req.headers.authorization?.split(class="keyword">class="string">" ")[1];

class="keyword">class="keyword">if (!token) {

class="keyword">class="keyword">return res.status(401).json({ error: class="keyword">class="string">"No token provided" });

}

class="keyword">try {

class="keyword">class="keyword">const decoded = jwt.verify(token, process.env.JWT_SECRET);

req.user = decoded;

next();

} class="keyword">catch (error) {

res.status(401).json({ error: class="keyword">class="string">"Invalid token" });

}

};

class="keyword">class="comment">// Route to services

app.use(

class="keyword">class="string">"/api/users",

createProxyMiddleware({

target: process.env.USER_SERVICE_URL,

changeOrigin: true,

pathRewrite: { class="keyword">class="string">"^/api/users": class="keyword">class="string">"" },

onProxyReq: (proxyReq, req) => {

class="keyword">class="comment">// Forward user info

class="keyword">class="keyword">if (req.user) {

proxyReq.setHeader(class="keyword">class="string">"X-User-Id", req.user.id);

proxyReq.setHeader(class="keyword">class="string">"X-User-Role", req.user.role);

}

},

})

);

app.use(

class="keyword">class="string">"/api/orders",

authenticate,

createProxyMiddleware({

target: process.env.ORDER_SERVICE_URL,

changeOrigin: true,

pathRewrite: { class="keyword">class="string">"^/api/orders": class="keyword">class="string">"" },

})

);

app.use(

class="keyword">class="string">"/api/products",

createProxyMiddleware({

target: process.env.PRODUCT_SERVICE_URL,

changeOrigin: true,

pathRewrite: { class="keyword">class="string">"^/api/products": class="keyword">class="string">"" },

})

);

class="keyword">class="comment">// Health check

app.get(class="keyword">class="string">"/health", (req, res) => {

res.json({ status: class="keyword">class="string">"healthy" });

});

class="keyword">class="keyword">const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {

console.log(class="keyword">class="string">API Gateway running on port ${PORT});

});

Service Discovery

Implement service registry for dynamic discovery.

script.js

class="keyword">class="comment">// service-registry/index.js

class="keyword">import express class="keyword">from class="keyword">class="string">"express";

class="keyword">class="keyword">const app = express();

app.use(express.json());

class="keyword">class="keyword">const services = class="keyword">new Map();

class="keyword">class="comment">// Register service

app.post(class="keyword">class="string">"/register", (req, res) => {

class="keyword">class="keyword">const { name, host, port, healthCheck } = req.body;

class="keyword">class="keyword">const serviceId = class="keyword">class="string">${name}-${Date.now()};

services.set(serviceId, {

id: serviceId,

name,

url: class="keyword">class="string">http:class="keyword">class="comment">//${host}:${port},

healthCheck,

registeredAt: class="keyword">new Date(),

lastHeartbeat: class="keyword">new Date(),

});

res.json({ serviceId });

});

class="keyword">class="comment">// Heartbeat

app.post(class="keyword">class="string">"/heartbeat/:serviceId", (req, res) => {

class="keyword">class="keyword">const service = services.get(req.params.serviceId);

class="keyword">class="keyword">if (service) {

service.lastHeartbeat = class="keyword">new Date();

res.json({ status: class="keyword">class="string">"ok" });

} class="keyword">class="keyword">else {

res.status(404).json({ error: class="keyword">class="string">"Service not found" });

}

});

class="keyword">class="comment">// Discover services

app.get(class="keyword">class="string">"/discover/:serviceName", (req, res) => {

class="keyword">class="keyword">const instances = Array.class="keyword">from(services.values())

.filter((s) => s.name === req.params.serviceName)

.filter((s) => {

class="keyword">class="keyword">const timeSinceHeartbeat = Date.now() - s.lastHeartbeat.getTime();

class="keyword">class="keyword">return timeSinceHeartbeat < 30000; class="keyword">class="comment">// 30 seconds

});

class="keyword">class="keyword">if (instances.length === 0) {

class="keyword">class="keyword">return res.status(404).json({ error: class="keyword">class="string">"No healthy instances" });

}

class="keyword">class="comment">// Round-robin load balancing

class="keyword">class="keyword">const instance = instances[Math.floor(Math.random() * instances.length)];

res.json(instance);

});

app.listen(8500, () => {

console.log(class="keyword">class="string">"Service registry running on port 8500");

});

Kubernetes Deployment

Deploy to production with Kubernetes.

config.yaml

# user-service/k8s/deployment.yaml

apiVersion: apps/v1

kind: Deployment

metadata:

name: user-service

labels:

app: user-service

spec:

replicas: 3

selector:

matchLabels:

app: user-service

template:

metadata:

labels:

app: user-service

spec:

containers:

- name: user-service

image: your-registry/user-service:latest

ports:

- containerPort: 3000

env:

- name: NODE_ENV

value: class="keyword">class="string">"production"

- name: DATABASE_URL

valueFrom:

secretKeyRef:

name: user-service-secrets

key: database-url

- name: JWT_SECRET

valueFrom:

secretKeyRef:

name: user-service-secrets

key: jwt-secret

resources:

requests:

memory: class="keyword">class="string">"256Mi"

cpu: class="keyword">class="string">"250m"

limits:

memory: class="keyword">class="string">"512Mi"

cpu: class="keyword">class="string">"500m"

livenessProbe:

httpGet:

path: /health

port: 3000

initialDelaySeconds: 30

periodSeconds: 10

readinessProbe:

httpGet:

path: /ready

port: 3000

initialDelaySeconds: 5

periodSeconds: 5

---

apiVersion: v1

kind: Service

metadata:

name: user-service

spec:

selector:

app: user-service

ports:

- protocol: TCP

port: 80

targetPort: 3000

class="keyword">type: ClusterIP

---

apiVersion: autoscaling/v2

kind: HorizontalPodAutoscaler

metadata:

name: user-service-hpa

spec:

scaleTargetRef:

apiVersion: apps/v1

kind: Deployment

name: user-service

minReplicas: 3

maxReplicas: 10

metrics:

- class="keyword">type: Resource

resource:

name: cpu

target:

class="keyword">type: Utilization

averageUtilization: 70

Monitoring and Observability

Track service health and performance.

script.js

class="keyword">class="comment">// monitoring/prometheus.js

class="keyword">import client class="keyword">from class="keyword">class="string">"prom-client";

class="keyword">class="keyword">const register = class="keyword">new client.Registry();

class="keyword">class="comment">// Default metrics

client.collectDefaultMetrics({ register });

class="keyword">class="comment">// Custom metrics

class="keyword">class="keyword">const httpRequestDuration = class="keyword">new client.Histogram({

name: class="keyword">class="string">"http_request_duration_seconds",

help: class="keyword">class="string">"Duration of HTTP requests in seconds",

labelNames: [class="keyword">class="string">"method", class="keyword">class="string">"route", class="keyword">class="string">"status_code"],

buckets: [0.1, 0.5, 1, 2, 5],

});

class="keyword">class="keyword">const httpRequestTotal = class="keyword">new client.Counter({

name: class="keyword">class="string">"http_requests_total",

help: class="keyword">class="string">"Total number of HTTP requests",

labelNames: [class="keyword">class="string">"method", class="keyword">class="string">"route", class="keyword">class="string">"status_code"],

});

register.registerMetric(httpRequestDuration);

register.registerMetric(httpRequestTotal);

class="keyword">class="comment">// Middleware

class="keyword">export class="keyword">class="keyword">const metricsMiddleware = (req, res, next) => {

class="keyword">class="keyword">const start = Date.now();

res.on(class="keyword">class="string">"finish", () => {

class="keyword">class="keyword">const duration = (Date.now() - start) / 1000;

httpRequestDuration

.labels(req.method, req.route?.path || req.path, res.statusCode)

.observe(duration);

httpRequestTotal

.labels(req.method, req.route?.path || req.path, res.statusCode)

.inc();

});

next();

};

class="keyword">export { register };

Best Practices

1. Database per Service

2. API Versioning

3. Circuit Breaker Pattern

4. Distributed Tracing

5. Centralized Logging

6. Automated Testing

7. CI/CD Pipeline

8. Security (mTLS, API Keys)

Conclusion

Microservices architecture enables building scalable, resilient systems. Start with a solid foundation: proper service boundaries, containerization, orchestration, and observability. Remember: microservices add complexity, so only adopt them when the benefits outweigh the costs.

Happy architecting! 🏗️