NestJS Backend
Guide d'intégration complet pour le monitoring du backend NestJS.
Informations
| Propriété | Valeur |
|---|---|
Label app |
nestjs-backend |
| Rétention | 14 jours (prod), 30 jours (erreurs) |
| Type de logs | JSON structuré via Logger NestJS |
Choisir votre méthode de déploiement
| Plateforme | Méthode |
|---|---|
| VPS (Contabo, etc.) | Docker Compose + Alloy |
| Railway | Push direct vers Loki |
| Autres PaaS | Push direct vers Loki |
Déploiement sur Railway
Railway ne supporte pas les sidecars Docker. Les logs sont envoyés directement à Loki depuis l'application.
Installation
src/common/logger/loki.logger.ts
import pino from 'pino';
const transport = pino.transport({
targets: [
// Console output
{
target: 'pino/file',
options: { destination: 1 }, // stdout
level: 'info',
},
// Loki output
{
target: 'pino-loki',
options: {
batching: true,
interval: 5,
host: process.env.LOKI_URL?.replace('/loki/api/v1/push', '') || '',
basicAuth: {
username: process.env.LOKI_USER || '',
password: process.env.LOKI_PASSWORD || '',
},
labels: {
app: 'nestjs-backend',
env: process.env.NODE_ENV || 'development',
host: process.env.RAILWAY_SERVICE_NAME || 'railway',
},
},
level: 'info',
},
],
});
export const logger = pino(
{
level: 'info',
formatters: {
level: (label) => ({ level: label }),
},
},
transport,
);
src/main.ts (Railway)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { logger } from './common/logger/loki.logger';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
});
// Use Pino logger
app.useLogger({
log: (message: string) => logger.info(message),
error: (message: string, trace?: string) => logger.error({ trace }, message),
warn: (message: string) => logger.warn(message),
debug: (message: string) => logger.debug(message),
verbose: (message: string) => logger.trace(message),
});
const port = process.env.PORT || 3000;
await app.listen(port);
logger.info({ port, env: process.env.NODE_ENV }, 'Application started');
}
bootstrap();
Variables Railway
Dans Railway Dashboard > Variables :
| Variable | Valeur |
|---|---|
LOKI_URL |
https://loki.monitoring.lyroh.com |
LOKI_USER |
loki-push |
LOKI_PASSWORD |
Votre mot de passe Loki |
NODE_ENV |
production |
Vérification
Après déploiement, dans Grafana :
Intégration Docker Compose
Pour les déploiements sur VPS avec Docker.
Structure du Projet
nestjs-backend/
├── .github/
│ └── workflows/
│ └── deploy.yml
├── docker-compose.yml
├── docker-compose.prod.yml
├── Dockerfile
├── alloy/
│ └── config.alloy
├── .env.example
├── package.json
├── tsconfig.json
└── src/
├── main.ts
├── app.module.ts
└── common/
├── logger/
│ └── json.logger.ts
└── interceptors/
└── logging.interceptor.ts
Dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/main.js"]
docker-compose.yml
# Note: 'version' is obsolete in Docker Compose v2+
services:
# ═══════════════════════════════════════════════════════════════
# API - NestJS Backend
# ═══════════════════════════════════════════════════════════════
api:
build: .
container_name: nestjs-api
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=${DATABASE_URL}
labels:
- "app=nestjs-backend"
- "service=api"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
# ═══════════════════════════════════════════════════════════════
# WORKER (optionnel) - Background jobs
# ═══════════════════════════════════════════════════════════════
worker:
build: .
container_name: nestjs-worker
restart: unless-stopped
command: ["node", "dist/worker.js"]
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
labels:
- "app=nestjs-backend"
- "service=worker"
# ═══════════════════════════════════════════════════════════════
# ALLOY - Log Collection
# ═══════════════════════════════════════════════════════════════
alloy:
image: grafana/alloy:latest
container_name: nestjs-backend-alloy
restart: unless-stopped
volumes:
- ./alloy/config.alloy:/etc/alloy/config.alloy:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- alloy_data:/var/lib/alloy
command:
- run
- --server.http.listen-addr=0.0.0.0:12345
- --storage.path=/var/lib/alloy
- /etc/alloy/config.alloy
environment:
- LOKI_URL=${LOKI_URL}
- LOKI_USER=${LOKI_USER}
- LOKI_PASSWORD=${LOKI_PASSWORD}
- APP_ENV=${APP_ENV:-prod}
- HOST_NAME=${HOST_NAME:-backend-server}
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:12345/ready"]
interval: 30s
timeout: 10s
retries: 3
volumes:
alloy_data:
alloy/config.alloy
// Discovery Docker
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
// Relabeling
discovery.relabel "docker_labels" {
targets = discovery.docker.containers.targets
rule {
source_labels = ["__meta_docker_container_name"]
regex = "/(.*)"
target_label = "container"
}
rule {
source_labels = ["__meta_docker_container_label_app"]
target_label = "app"
}
rule {
source_labels = ["__meta_docker_container_label_service"]
target_label = "service"
}
rule {
source_labels = ["app"]
regex = ".+"
action = "keep"
}
}
// Source
loki.source.docker "docker_logs" {
host = "unix:///var/run/docker.sock"
targets = discovery.relabel.docker_labels.output
labels = {
"env" = env("APP_ENV"),
"host" = env("HOST_NAME"),
}
forward_to = [loki.process.pipeline.receiver]
}
// Processing Pipeline
loki.process "pipeline" {
forward_to = [loki.write.loki_remote.receiver]
// Parse NestJS JSON logs
stage.json {
expressions = {
level = "level",
message = "message",
context = "context",
timestamp = "timestamp",
method = "method",
url = "url",
statusCode = "statusCode",
duration = "duration",
userId = "userId",
requestId = "requestId",
}
}
// Normalize log levels
stage.replace {
expression = "(?i)(LOG|INFO)"
replace = "info"
source = "level"
}
stage.replace {
expression = "(?i)(WARN|WARNING)"
replace = "warn"
source = "level"
}
stage.replace {
expression = "(?i)(ERROR|ERR)"
replace = "error"
source = "level"
}
stage.labels {
values = {
level = "",
context = "",
service = "",
}
}
// Drop health checks (optional)
// stage.drop {
// expression = "(?i)/health"
// }
stage.drop {
expression = "^\\s*$"
}
}
// Send to Loki
loki.write "loki_remote" {
endpoint {
url = env("LOKI_URL")
basic_auth {
username = env("LOKI_USER")
password = env("LOKI_PASSWORD")
}
}
}
.env.example
# Application
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/db
# Monitoring
LOKI_URL=https://loki.monitoring.lyroh.com/loki/api/v1/push
LOKI_USER=loki-push
LOKI_PASSWORD=your_password_here
APP_ENV=prod
HOST_NAME=backend-server-1
Configuration NestJS
src/common/logger/json.logger.ts
import { ConsoleLogger, Injectable, LogLevel } from '@nestjs/common';
interface LogObject {
timestamp: string;
level: string;
context?: string;
message: string;
[key: string]: any;
}
@Injectable()
export class JsonLogger extends ConsoleLogger {
private formatLog(
level: string,
message: string,
context?: string,
meta?: Record<string, any>,
): string {
const log: LogObject = {
timestamp: new Date().toISOString(),
level: level.toUpperCase(),
context: context || this.context || 'Application',
message,
...meta,
};
return JSON.stringify(log);
}
log(message: string, context?: string): void;
log(message: string, ...optionalParams: any[]): void;
log(message: string, ...optionalParams: any[]): void {
const context = optionalParams[0] as string | undefined;
console.log(this.formatLog('INFO', message, context));
}
error(message: string, trace?: string, context?: string): void;
error(message: string, ...optionalParams: any[]): void;
error(message: string, ...optionalParams: any[]): void {
const [traceOrContext, context] = optionalParams;
const meta = typeof traceOrContext === 'string' && traceOrContext.includes('\n')
? { trace: traceOrContext }
: {};
console.error(this.formatLog('ERROR', message, context || traceOrContext, meta));
}
warn(message: string, context?: string): void;
warn(message: string, ...optionalParams: any[]): void;
warn(message: string, ...optionalParams: any[]): void {
const context = optionalParams[0] as string | undefined;
console.warn(this.formatLog('WARN', message, context));
}
debug(message: string, context?: string): void;
debug(message: string, ...optionalParams: any[]): void;
debug(message: string, ...optionalParams: any[]): void {
if (process.env.NODE_ENV !== 'production') {
const context = optionalParams[0] as string | undefined;
console.debug(this.formatLog('DEBUG', message, context));
}
}
verbose(message: string, context?: string): void;
verbose(message: string, ...optionalParams: any[]): void;
verbose(message: string, ...optionalParams: any[]): void {
if (process.env.NODE_ENV !== 'production') {
const context = optionalParams[0] as string | undefined;
console.log(this.formatLog('VERBOSE', message, context));
}
}
// Custom method for structured logs
logJson(level: LogLevel, message: string, meta: Record<string, any>): void {
console.log(this.formatLog(level, message, this.context, meta));
}
}
src/common/interceptors/logging.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request, Response } from 'express';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const { method, url, ip, headers } = request;
const userAgent = headers['user-agent'] || '';
const requestId = headers['x-request-id'] || this.generateRequestId();
const userId = (request as any).user?.id;
const start = Date.now();
return next.handle().pipe(
tap({
next: () => {
const duration = Date.now() - start;
const { statusCode } = response;
console.log(
JSON.stringify({
timestamp: new Date().toISOString(),
level: 'INFO',
context: 'HTTP',
message: `${method} ${url} ${statusCode}`,
method,
url,
statusCode,
duration,
ip,
userAgent,
requestId,
userId,
}),
);
},
error: (error) => {
const duration = Date.now() - start;
const statusCode = error.status || 500;
console.error(
JSON.stringify({
timestamp: new Date().toISOString(),
level: 'ERROR',
context: 'HTTP',
message: `${method} ${url} ${statusCode} - ${error.message}`,
method,
url,
statusCode,
duration,
ip,
userAgent,
requestId,
userId,
error: error.message,
stack: error.stack,
}),
);
},
}),
);
}
private generateRequestId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}
src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { JsonLogger } from './common/logger/json.logger';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
});
// Use JSON logger
app.useLogger(new JsonLogger());
// Global logging interceptor
app.useGlobalInterceptors(new LoggingInterceptor());
const port = process.env.PORT || 3000;
await app.listen(port);
console.log(
JSON.stringify({
timestamp: new Date().toISOString(),
level: 'INFO',
context: 'Bootstrap',
message: `Application started on port ${port}`,
port,
env: process.env.NODE_ENV,
}),
);
}
bootstrap();
CI/CD
Stratégie de Branches
| Branche | Environnement | APP_ENV |
Serveur |
|---|---|---|---|
staging |
Staging | staging |
STAGING_SERVER_HOST |
main |
Production | prod |
PROD_SERVER_HOST |
Un seul stack monitoring
Staging et production envoient leurs logs au même serveur de monitoring. Le label env (staging ou prod) permet de les distinguer dans Grafana.
Guide complet CI/CD
Voir le guide détaillé : CI/CD GitHub Actions
GitHub Actions - .github/workflows/deploy.yml
name: Deploy NestJS Backend
on:
push:
branches: [main, staging]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Set environment
id: env
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "ENV=prod" >> $GITHUB_OUTPUT
else
echo "ENV=staging" >> $GITHUB_OUTPUT
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ steps.env.outputs.ENV }}
type=sha,prefix=
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
deploy-staging:
if: github.ref_name == 'staging'
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Copy config files
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.STAGING_SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: "docker-compose.yml,docker-compose.staging.yml,alloy/"
target: "/opt/nestjs-backend"
- name: Deploy to Staging
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.STAGING_SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /opt/nestjs-backend
cat > .env << 'EOF'
NODE_ENV=staging
PORT=3000
DATABASE_URL=${{ secrets.DATABASE_URL_STAGING }}
LOKI_URL=${{ secrets.LOKI_URL }}
LOKI_USER=${{ secrets.LOKI_USER }}
LOKI_PASSWORD=${{ secrets.LOKI_PASSWORD }}
APP_ENV=staging
HOST_NAME=$(hostname)
EOF
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker-compose -f docker-compose.yml -f docker-compose.staging.yml pull
docker-compose -f docker-compose.yml -f docker-compose.staging.yml up -d
deploy-prod:
if: github.ref_name == 'main'
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Copy config files
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.PROD_SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: "docker-compose.yml,docker-compose.prod.yml,alloy/"
target: "/opt/nestjs-backend"
- name: Deploy to Production
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PROD_SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /opt/nestjs-backend
cat > .env << 'EOF'
NODE_ENV=production
PORT=3000
DATABASE_URL=${{ secrets.DATABASE_URL }}
LOKI_URL=${{ secrets.LOKI_URL }}
LOKI_USER=${{ secrets.LOKI_USER }}
LOKI_PASSWORD=${{ secrets.LOKI_PASSWORD }}
APP_ENV=prod
HOST_NAME=$(hostname)
EOF
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Secrets GitHub Requis
Configurez ces secrets dans Settings > Secrets and variables > Actions :
| Secret | Description |
|---|---|
STAGING_SERVER_HOST |
IP/hostname serveur staging |
PROD_SERVER_HOST |
IP/hostname serveur production |
SERVER_USER |
Utilisateur SSH |
SERVER_SSH_KEY |
Clé privée SSH |
DATABASE_URL_STAGING |
URL DB staging |
DATABASE_URL |
URL DB production |
LOKI_URL |
https://loki.monitoring.lyroh.com/loki/api/v1/push |
LOKI_USER |
loki-push |
LOKI_PASSWORD |
Mot de passe Loki |
docker-compose.staging.yml
# Note: 'version' is obsolete in Docker Compose v2+
services:
api:
image: ghcr.io/your-org/nestjs-backend:staging
restart: unless-stopped
labels:
- "app=nestjs-backend"
- "env=staging"
worker:
image: ghcr.io/your-org/nestjs-backend:staging
restart: unless-stopped
alloy:
environment:
- APP_ENV=staging
docker-compose.prod.yml
# Note: 'version' is obsolete in Docker Compose v2+
services:
api:
image: ghcr.io/your-org/nestjs-backend:prod
restart: unless-stopped
labels:
- "app=nestjs-backend"
- "env=prod"
worker:
image: ghcr.io/your-org/nestjs-backend:prod
restart: unless-stopped
alloy:
environment:
- APP_ENV=prod
Requêtes LogQL Utiles
# Tous les logs du backend
{app="nestjs-backend", env="prod"}
# Par service (api ou worker)
{app="nestjs-backend", service="api"}
{app="nestjs-backend", service="worker"}
# Erreurs HTTP 5xx
{app="nestjs-backend"} | json | statusCode >= 500
# Requêtes lentes (> 1 seconde)
{app="nestjs-backend"} | json | duration > 1000
# Logs par contexte NestJS
{app="nestjs-backend"} | json | context="AuthService"
# Erreurs avec stack trace
{app="nestjs-backend"} | json | level="ERROR" | line_format "{{.context}}: {{.message}}\n{{.stack}}"
# Requêtes par endpoint
sum by (url) (count_over_time({app="nestjs-backend"} | json [1h]))
# Latence moyenne par endpoint
avg by (url) ({app="nestjs-backend"} | json | unwrap duration | __error__="")
# Distribution des codes HTTP
sum by (statusCode) (count_over_time({app="nestjs-backend"} | json [1h]))