Tutorial NestJS Prisma: Crea una API REST de producción con TypeScript, PostgreSQL, JWT, Swagger y Docker
¿Buscas un tutorial NestJS Prisma completo para crear una API REST NestJS lista para producción? En esta guía didáctica y paso a paso construimos una API con NestJS + TypeScript, Prisma PostgreSQL como ORM/BD, seguridad con NestJS JWT (access y refresh), documentación OpenAPI NestJS (Swagger), pruebas con Jest/Supertest, observabilidad, caché con Redis y despliegue con Docker NestJS. Ideal para desarrolladores backend principiantes e intermedios.
1) Requisitos e instalación
- Node.js 20+
- npm o pnpm, npx
- Git
- Docker y Docker Compose
- Postman/Insomnia (opcional)
Verificaciones rápidas:
1 2 3 4 5 6 7 8 9 10 11 |
node -v npm -v # o pnpm -v docker --version docker compose version # Opcional: cliente de PostgreSQL psql --version |
Checklist
- Node.js y gestor de paquetes instalados
- Docker y Compose funcionando
- Repositorio Git inicializado
2) Inicialización del proyecto con Nest CLI, ESLint/Prettier y Git Hooks
Crea un proyecto NestJS:
1 2 3 4 |
npm i -g @nestjs/cli nest new api-nest-prisma cd api-nest-prisma |
Estructura básica de carpetas (resumen):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
src/ app.module.ts main.ts common/ filters/ interceptors/ guards/ pipes/ auth/ users/ posts/ prisma/ schema.prisma seed.ts |
Configura ESLint y Prettier (ya vienen con Nest). Añade Husky + lint-staged + Commitlint para calidad de commits:
1 2 3 4 5 6 7 8 9 |
npm i -D husky lint-staged @commitlint/{config-conventional,cli} npx husky install echo "module.exports = {extends: ['@commitlint/config-conventional']};" > commitlint.config.cjs npx husky add .husky/commit-msg "npx --no -- commitlint --edit $1" # lint-staged en package.json |
package.json (fragmento):
1 2 3 4 5 6 7 8 9 10 11 12 |
{ "scripts": { "prepare": "husky install" }, "lint-staged": { "*.{ts,js,json,md}": [ "prettier --write", "eslint --fix" ] } } |
Checklist
- Proyecto Nest creado
- Hooks de Git (Husky, lint-staged) y Commitlint activos
3) Configuración por entornos con @nestjs/config y validación
Instala dependencias:
1 2 |
npm i @nestjs/config joi helmet compression @nestjs/throttler |
Crea un esquema de validación con Joi y usa archivos .env por ambiente (dev/test/prod) siguiendo 12-Factor.
Archivo: .env.example
1 2 3 4 5 6 7 8 9 10 11 12 |
NODE_ENV=development PORT=3000 DATABASE_URL="postgresql://postgres:postgres@localhost:5432/appdb?schema=public&connection_limit=5&statement_timeout=60000" JWT_ACCESS_SECRET=supersecretaccess JWT_ACCESS_TTL=900s JWT_REFRESH_SECRET=supersecretrefresh JWT_REFRESH_TTL=7d THROTTLE_TTL=60 THROTTLE_LIMIT=60 REDIS_URL=redis://localhost:6379 SWAGGER_ENABLED=true |
Copia .env.example a .env y ajusta valores por entorno.
Checklist
- .env.example creado
- Validación de variables planificada
4) Base de datos con Prisma + PostgreSQL: modelos, migraciones y seed
Instala Prisma y el cliente:
1 2 3 |
npm i prisma @prisma/client npx prisma init |
prisma/schema.prisma
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique passwordHash String roles String[] @default(["user"]) // simple array de roles refreshTokenHash String? // para rotación de refresh tokens posts Post[] comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId]) } model Comment { id String @id @default(cuid()) content String authorId String postId String author User @relation(fields: [authorId], references: [id]) post Post @relation(fields: [postId], references: [id]) createdAt DateTime @default(now()) } |
Migraciones y seed:
1 2 |
npx prisma migrate dev --name init |
prisma/seed.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
import { PrismaClient } from '@prisma/client'; import * as argon2 from 'argon2'; const prisma = new PrismaClient(); async function main() { const passwordHash = await argon2.hash('changeme'); const user = await prisma.user.upsert({ where: { email: 'admin@example.com' }, update: {}, create: { email: 'admin@example.com', passwordHash, roles: ['admin'] }, }); await prisma.post.create({ data: { title: 'Primer Post', content: 'Contenido inicial', authorId: user.id, published: true, }, }); } main().finally(() => prisma.$disconnect()); |
package.json (scripts de Prisma):
1 2 3 4 5 6 7 8 9 |
{ "scripts": { "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "prisma:seed": "ts-node prisma/seed.ts" } } |
Transacciones y pool: Prisma usa pooling del driver. Ajusta connectionlimit y timeouts en DATABASEURL. Para transacciones:
1 2 3 4 5 |
await prisma.$transaction(async (tx) => { const post = await tx.post.create({ data: { title, content, authorId } }); await tx.comment.create({ data: { content: 'Hola', authorId, postId: post.id } }); }); |
Checklist
- Modelos User, Post, Comment definidos
- Migraciones aplicadas y seed ejecutado
- URL de BD con parámetros de pool/timeout
5) Arquitectura NestJS: módulos, DTOs, pipes, filtros e interceptores
src/app.module.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi'; import { ThrottlerModule } from '@nestjs/throttler'; import { CacheModule } from '@nestjs/cache-manager'; import { redisStore } from 'cache-manager-redis-yet'; import { TerminusModule } from '@nestjs/terminus'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { PostsModule } from './posts/posts.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validationSchema: Joi.object({ NODE_ENV: Joi.string().valid('development', 'test', 'production').required(), PORT: Joi.number().default(3000), DATABASE_URL: Joi.string().uri().required(), JWT_ACCESS_SECRET: Joi.string().required(), JWT_ACCESS_TTL: Joi.string().default('900s'), JWT_REFRESH_SECRET: Joi.string().required(), JWT_REFRESH_TTL: Joi.string().default('7d'), THROTTLE_TTL: Joi.number().default(60), THROTTLE_LIMIT: Joi.number().default(60), REDIS_URL: Joi.string().allow('', null), SWAGGER_ENABLED: Joi.boolean().default(true), }), }), ThrottlerModule.forRoot([ { ttl: parseInt(process.env.THROTTLE_TTL || '60'), limit: parseInt(process.env.THROTTLE_LIMIT || '60'), }, ]), CacheModule.registerAsync({ isGlobal: true, useFactory: async () => ({ store: process.env.REDIS_URL ? await redisStore({ url: process.env.REDIS_URL }) : undefined, ttl: 5_000, }), }), TerminusModule, AuthModule, UsersModule, PostsModule, ], }) export class AppModule {} |
src/common/filters/http-exception.filter.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(HttpExceptionFilter.name); catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; const message = exception instanceof HttpException ? exception.getResponse() : 'Internal server error'; this.logger.error(`${request.method} ${request.url} -> ${status}`, '', exception instanceof Error ? exception.stack : undefined); response.status(status).json({ statusCode: status, path: request.url, timestamp: new Date().toISOString(), message, requestId: request.headers['x-request-id'], }); } } |
src/common/interceptors/logging.interceptor.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from '@nestjs/common'; import { Observable, tap } from 'rxjs'; import { randomUUID } from 'crypto'; @Injectable() export class LoggingInterceptor implements NestInterceptor { private readonly logger = new Logger(LoggingInterceptor.name); intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const req = context.switchToHttp().getRequest(); req.headers['x-request-id'] ||= randomUUID(); const { method, url } = req; const started = Date.now(); return next.handle().pipe( tap(() => this.logger.log(`${method} ${url} ${Date.now() - started}ms [${req.headers['x-request-id']}]`)), ); } } |
src/common/guards/roles.guard.ts y decorador @Roles
1 2 3 4 5 |
// src/common/decorators/roles.decorator.ts import { SetMetadata } from '@nestjs/common'; export const ROLES_KEY = 'roles'; export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// src/common/guards/roles.guard.ts import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { ROLES_KEY } from '../decorators/roles.decorator'; @Injectable() export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(ctx: ExecutionContext): boolean { const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [ ctx.getHandler(), ctx.getClass(), ]); if (!required?.length) return true; const { user } = ctx.switchToHttp().getRequest(); return required.some((r) => user?.roles?.includes(r)); } } |
Pipes personalizados (ejemplo: paginación)
1 2 3 4 5 6 7 8 9 10 11 12 |
// src/common/pipes/pagination.pipe.ts import { BadRequestException, PipeTransform } from '@nestjs/common'; export class PaginationPipe implements PipeTransform { transform(value: any) { const page = Math.max(1, parseInt(value.page ?? '1')); const limit = Math.min(100, Math.max(1, parseInt(value.limit ?? '10'))); if (Number.isNaN(page) || Number.isNaN(limit)) throw new BadRequestException('Invalid pagination'); return { ...value, page, limit }; } } |
Checklist
- Módulos definidos
- Validation/Filters/Interceptors/Guards listos
- Pipe de paginación disponible
6) Seguridad y autenticación: NestJS JWT (access y refresh), Passport, CORS, Helmet y rate limiting
Instala dependencias de auth:
1 2 |
npm i @nestjs/passport passport @nestjs/jwt passport-jwt argon2 |
src/auth/auth.module.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; import { RefreshStrategy } from './refresh.strategy'; import { UsersModule } from '../users/users.module'; @Module({ imports: [ UsersModule, PassportModule, JwtModule.register({}), ], providers: [AuthService, JwtStrategy, RefreshStrategy, ConfigService], exports: [AuthService], }) export class AuthModule {} |
src/auth/auth.service.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
import { Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as argon2 from 'argon2'; import { PrismaClient } from '@prisma/client'; @Injectable() export class AuthService { private prisma = new PrismaClient(); constructor(private jwt: JwtService, private cfg: ConfigService) {} async register(email: string, password: string) { const passwordHash = await argon2.hash(password); const user = await this.prisma.user.create({ data: { email, passwordHash, roles: ['user'] } }); return this.issueTokens(user.id, user.roles); } async login(email: string, password: string) { const user = await this.prisma.user.findUnique({ where: { email } }); if (!user || !(await argon2.verify(user.passwordHash, password))) throw new UnauthorizedException('Invalid credentials'); return this.issueTokens(user.id, user.roles); } private async issueTokens(sub: string, roles: string[]) { const accessPayload = { sub, roles, type: 'access' }; const refreshPayload = { sub, roles, type: 'refresh' }; const access = await this.jwt.signAsync(accessPayload, { secret: this.cfg.get('JWT_ACCESS_SECRET'), expiresIn: this.cfg.get('JWT_ACCESS_TTL'), }); const refresh = await this.jwt.signAsync(refreshPayload, { secret: this.cfg.get('JWT_REFRESH_SECRET'), expiresIn: this.cfg.get('JWT_REFRESH_TTL'), }); await this.prisma.user.update({ where: { id: sub }, data: { refreshTokenHash: await argon2.hash(refresh) } }); return { accessToken: access, refreshToken: refresh }; } async rotateRefreshToken(userId: string, refreshToken: string) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user?.refreshTokenHash) throw new ForbiddenException('No refresh token'); const valid = await argon2.verify(user.refreshTokenHash, refreshToken); if (!valid) throw new ForbiddenException('Invalid refresh token'); return this.issueTokens(userId, user.roles); } } |
src/auth/jwt.strategy.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { constructor(cfg: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: cfg.get('JWT_ACCESS_SECRET'), }); } validate(payload: any) { return payload; // { sub, roles, type } } } |
src/auth/refresh.strategy.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; @Injectable() export class RefreshStrategy extends PassportStrategy(Strategy, 'refresh') { constructor(cfg: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: cfg.get('JWT_REFRESH_SECRET'), passReqToCallback: true, }); } validate(req: any, payload: any) { const refreshToken = ExtractJwt.fromAuthHeaderAsBearerToken()(req); return { ...payload, refreshToken }; } } |
Checklist
- Registro y login con hash de contraseñas (argon2)
- JWT access/refresh con rotación
- Strategies configuradas
7) Endpoints y negocio: CRUD de usuarios y posts, paginación y versionado
DTOs con class-validator y class-transformer:
1 2 |
npm i class-validator class-transformer |
src/users/dto/create-user.dto.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsString, MinLength } from 'class-validator'; export class CreateUserDto { @ApiProperty() @IsEmail() email: string; @ApiProperty() @IsString() @MinLength(8) password: string; } |
src/users/users.service.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import * as argon2 from 'argon2'; @Injectable() export class UsersService { private prisma = new PrismaClient(); async create(dto: { email: string; password: string }) { const passwordHash = await argon2.hash(dto.password); return this.prisma.user.create({ data: { email: dto.email, passwordHash } }); } async findMany(page: number, limit: number) { const [items, total] = await this.prisma.$transaction([ this.prisma.user.findMany({ skip: (page - 1) * limit, take: limit }), this.prisma.user.count(), ]); return { items, total, page, limit }; } async findOne(id: string) { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new NotFoundException('User not found'); return user; } } |
src/users/users.controller.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import { Body, Controller, Get, Param, Post, Query, UseGuards, UsePipes } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { UsersService } from './users.service'; import { CreateUserDto } from './dto/create-user.dto'; import { PaginationPipe } from '../common/pipes/pagination.pipe'; @ApiTags('users') @ApiBearerAuth() @UseGuards(AuthGuard('jwt')) @Controller({ path: 'users', version: '1' }) export class UsersController { constructor(private readonly users: UsersService) {} @Post() create(@Body() dto: CreateUserDto) { return this.users.create(dto); } @Get() @UsePipes(new PaginationPipe()) list(@Query() { page, limit }: any) { return this.users.findMany(page, limit); } @Get(':id') get(@Param('id') id: string) { return this.users.findOne(id); } } |
src/users/users.module.ts
1 2 3 4 5 6 7 |
import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; @Module({ controllers: [UsersController], providers: [UsersService], exports: [UsersService] }) export class UsersModule {} |
Posts (resumen):
src/posts/dto/create-post.dto.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsOptional, IsBoolean } from 'class-validator'; export class CreatePostDto { @ApiProperty() @IsString() title: string; @ApiProperty() @IsString() content: string; @ApiProperty({ default: false }) @IsBoolean() @IsOptional() published?: boolean = false; } |
src/posts/posts.service.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import { Injectable } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PostsService { private prisma = new PrismaClient(); create(authorId: string, dto: { title: string; content: string; published?: boolean }) { return this.prisma.post.create({ data: { ...dto, authorId } }); } list(page: number, limit: number, q?: string) { return this.prisma.post.findMany({ skip: (page - 1) * limit, take: limit, where: q ? { OR: [{ title: { contains: q, mode: 'insensitive' } }, { content: { contains: q, mode: 'insensitive' } }] } : undefined, orderBy: { createdAt: 'desc' }, }); } } |
src/posts/posts.controller.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import { Body, Controller, Get, Post as PostMethod, Query, Req, UseGuards, UsePipes } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { PostsService } from './posts.service'; import { CreatePostDto } from './dto/create-post.dto'; import { PaginationPipe } from '../common/pipes/pagination.pipe'; @ApiTags('posts') @ApiBearerAuth() @UseGuards(AuthGuard('jwt')) @Controller({ path: 'posts', version: '1' }) export class PostsController { constructor(private posts: PostsService) {} @PostMethod() create(@Req() req: any, @Body() dto: CreatePostDto) { return this.posts.create(req.user.sub, dto); } @Get() @UsePipes(new PaginationPipe()) list(@Query() { page, limit, q }: any) { return this.posts.list(page, limit, q); } } |
Ejemplos con curl:
1 2 3 4 5 6 7 8 9 10 11 |
# Registro curl -X POST http://localhost:3000/api/v1/auth/register -H 'Content-Type: application/json' -d '{"email":"user@example.com","password":"secret123"}' # Login curl -X POST http://localhost:3000/api/v1/auth/login -H 'Content-Type: application/json' -d '{"email":"user@example.com","password":"secret123"}' # Crear post curl -X POST http://localhost:3000/api/v1/posts \ -H "Authorization: Bearer <ACCESS_TOKEN>" -H 'Content-Type: application/json' \ -d '{"title":"Hola","content":"Mundo"}' |
Checklist
- CRUD básico de usuarios y posts
- Paginación/filtrado implementado
- Versionado v1 en rutas
8) Documentación OpenAPI/Swagger en NestJS
src/main.ts (Bootstrap con Swagger, CORS, Helmet, Throttler y ValidationPipe)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe, VersioningType } from '@nestjs/common'; import helmet from 'helmet'; import compression from 'compression'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; async function bootstrap() { const app = await NestFactory.create(AppModule, { bufferLogs: true }); app.setGlobalPrefix('api'); app.enableVersioning({ type: VersioningType.URI }); app.use(helmet()); app.use(compression()); app.enableCors({ origin: [/localhost:\d+$/], credentials: true }); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new LoggingInterceptor()); if (process.env.SWAGGER_ENABLED === 'true') { const config = new DocumentBuilder() .setTitle('API REST NestJS') .setDescription('Documentación OpenAPI NestJS con Swagger') .setVersion('1.0') .addBearerAuth() .build(); const doc = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs', app, doc, { swaggerOptions: { persistAuthorization: true } }); } await app.listen(process.env.PORT || 3000); } bootstrap(); |
Checklist
- Swagger habilitado con BearerAuth
- DTOs anotados con ApiProperty
- Documentación accesible en /api/docs
9) Logging, salud y monitorización (Terminus, request ID y métricas)
Health endpoints con @nestjs/terminus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// src/health/health.controller.ts import { Controller, Get } from '@nestjs/common'; import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus'; import { PrismaClient } from '@prisma/client'; @Controller('health') export class HealthController { private prisma = new PrismaClient(); constructor(private health: HealthCheckService) {} @Get('liveness') @HealthCheck() check() { return this.health.check([async () => ({ prisma: (await this.prisma.$queryRaw`SELECT 1`) ? { status: 'up' } : { status: 'down' } })]); } } |
Opcional: métricas Prometheus exponiendo /metrics con prom-client.
Checklist
- Endpoint de liveness listo
- Logs con request ID
- Métricas opcionales planificadas
10) Caché con Redis (opcional) usando @nestjs/cache-manager
Ejemplo de uso en PostsService:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; import { Cache } from 'cache-manager'; @Injectable() export class PostsService { constructor(@Inject(CACHE_MANAGER) private cache: Cache) {} // ... prisma omitido por brevedad async cachedList(key: string, fn: () => Promise<any>) { const hit = await this.cache.get(key); if (hit) return hit; const data = await fn(); await this.cache.set(key, data, 5000); return data; } } |
Estrategias de invalidación: invalidar por claves asociadas a entidades al crear/actualizar/eliminar.
Checklist
- Redis opcional conectado
- Política de TTL e invalidación definida
11) Pruebas con Jest y Supertest: unitarias y E2E
Instala Supertest:
1 2 |
npm i -D supertest @types/supertest ts-jest |
jest.config.ts
1 2 3 4 5 6 7 8 9 10 |
export default { moduleFileExtensions: ['js', 'json', 'ts'], rootDir: '.', testRegex: '.*\\.spec\\.ts$', transform: { '^.+\\.(t|j)s$': 'ts-jest' }, collectCoverageFrom: ['src/**/*.(t|j)s'], coverageDirectory: './coverage', testEnvironment: 'node', }; |
test/app.e2e-spec.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import { Test } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from '../src/app.module'; describe('App E2E', () => { let app: INestApplication; beforeAll(async () => { const modRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = modRef.createNestApplication(); app.useGlobalPipes(new ValidationPipe({ whitelist: true })); await app.init(); }); it('/health/liveness (GET)', async () => { await request(app.getHttpServer()).get('/health/liveness').expect(200); }); afterAll(async () => app.close()); }); |
BD de pruebas: usa una DATABASE_URL de test y ejecuta npx prisma migrate reset –force antes de los E2E (via script de npm o setupFile).
Checklist
- Config Jest listo
- Pruebas E2E básicas operativas
- Reset de BD de test definido
12) Dockerización y orquestación local con Docker Compose
Dockerfile (multi-stage)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# Stage builder FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY prisma ./prisma RUN npx prisma generate COPY tsconfig*.json nest-cli.json ./ COPY src ./src RUN npm run build # Stage runtime FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/prisma ./prisma EXPOSE 3000 CMD ["node", "dist/main.js"] |
docker-compose.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
version: '3.9' services: db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres POSTGRES_DB: appdb ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 5 redis: image: redis:7-alpine ports: - "6379:6379" api: build: . depends_on: db: condition: service_healthy environment: NODE_ENV: development PORT: 3000 DATABASE_URL: postgresql://postgres:postgres@db:5432/appdb?schema=public&connection_limit=5 JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET} REDIS_URL: redis://redis:6379 SWAGGER_ENABLED: 'true' ports: - "3000:3000" |
Comandos útiles:
1 2 |
docker compose up -d --build |
Checklist
- Dockerfile multi-stage creado
- Compose con API, Postgres y Redis
- Variables de entorno seguras (usa secrets en prod)
13) CI/CD básico con GitHub Actions
.github/workflows/ci.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
name: CI on: [push, pull_request] jobs: build-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx prisma generate - run: npm run lint --if-present - run: npm test -- --ci --runInBand - run: npm run build docker: needs: build-test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v5 with: push: true tags: ghcr.io/${{ github.repository }}:latest |
Checklist
- Workflow con install, lint, test, build
- Build/push de imagen a registro
- Secrets gestionados en GitHub
14) Rendimiento y buenas prácticas de producción
- HTTP keep-alive activo por defecto en Node 20
- Compresión con compression (cuidado con payloads ya comprimidos)
- Limita payloads y valida con class-validator
- Índices en BD: añade @@index en campos consultados
- Paginación eficiente: usa cursor-based para listas grandes
- Pooling/Timeouts en DATABASEURL (statementtimeout, connect_timeout)
- Consultas preparadas y parametrizadas (Prisma ya parametriza)
- Rate limiting con @nestjs/throttler
- Escalado: PM2 o cluster mode (Kubernetes), replica horizontal detrás de un balanceador
- Reintentos/backoff en clientes externos
Checklist
- Validaciones y límites definidos
- Timeouts/pooling ajustados
- Estrategia de escalado contemplada
15) Cierre, troubleshoot y próximos pasos
Problemas comunes y solución
- Prisma Client no generado: ejecuta npx prisma generate
- Migraciones fallan: revisa DATABASE_URL, corre npx prisma migrate reset –force en dev
- CORS bloquea peticiones: ajusta app.enableCors({ origin: […] })
- JWT expirado: renueva con /auth/refresh usando el refresh token
- Error EADDRINUSE: cambia PORT o libera el puerto
- Docker Postgres no levanta: verifica healthcheck y logs docker compose logs db
Checklist global final
- API REST NestJS con endpoints versionados
- Seguridad con JWT access/refresh, Helmet, CORS, rate limit
- Prisma PostgreSQL con migraciones/seed y transacciones
- Documentación Swagger OpenAPI
- Pruebas unitarias/E2E con Jest/Supertest
- Observabilidad: logs, health y métricas opcionales
- Dockerización y pipeline CI listos
Próximos pasos
- Autorización granular por recurso y ownership
- Colas con BullMQ para trabajos asíncronos
- Subida de archivos a S3/GCS
- Auditoría y trazas con OpenTelemetry (OTLP, Jaeger/Tempo)
Archivos clave restantes y utilidades
Rutas de Auth (controlador rápido)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// src/auth/auth.controller.ts import { Body, Controller, Post, UseGuards, Req } from '@nestjs/common'; import { AuthService } from './auth.service'; import { AuthGuard } from '@nestjs/passport'; @Controller({ path: 'auth', version: '1' }) export class AuthController { constructor(private auth: AuthService) {} @Post('register') register(@Body() body: { email: string; password: string }) { return this.auth.register(body.email, body.password); } @Post('login') login(@Body() body: { email: string; password: string }) { return this.auth.login(body.email, body.password); } @UseGuards(AuthGuard('refresh')) @Post('refresh') refresh(@Req() req: any) { return this.auth.rotateRefreshToken(req.user.sub, req.user.refreshToken); } } |
nest-cli.json
1 2 3 4 5 |
{ "collection": "@nestjs/schematics", "sourceRoot": "src" } |
package.json (scripts útiles)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
{ "scripts": { "start": "nest start", "start:dev": "nest start --watch", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"prisma/**/*.ts\"", "lint": "eslint \"{src,tests}/**/*.ts\"", "test": "jest", "test:e2e": "jest --config jest.config.ts --runTestsByPath test/app.e2e-spec.ts", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:seed": "ts-node prisma/seed.ts", "docker:up": "docker compose up -d", "docker:build": "docker build -t api-nest-prisma ." } } |
Resolución de problemas comunes.
- ¿Qué es Prisma y por qué usarlo con NestJS?
- Prisma es un ORM moderno con tipado fuerte y migraciones. Con NestJS acelera el desarrollo y reduce errores en acceso a datos.
- ¿Cómo proteger una API REST NestJS con JWT?
- Implementa estrategias de Passport para access y refresh tokens, guarda hash del refresh y rota tokens en cada renovación.
- ¿Cómo documentar una API NestJS con OpenAPI?
- Usa @nestjs/swagger, anota DTOs con ApiProperty y habilita SwaggerModule en main.ts para exponer /api/docs.
- ¿Cómo ejecutar la API en Docker?
- Define un Dockerfile multi-stage, orquesta con docker-compose (API + Postgres + Redis) y configura variables de entorno seguras.
- ¿Cómo probar con Jest y Supertest?
- Crea pruebas unitarias/E2E, configura jest.config.ts y usa Supertest contra el servidor Nest; resetea la BD de pruebas con Prisma.
Conclusión
Has construido una API REST NestJS de calidad producción con Prisma PostgreSQL, seguridad con NestJS JWT, documentación OpenAPI NestJS, pruebas y despliegue con Docker NestJS. Sigue los checklists, añade monitoreo profundo y CI/CD para llevarla a producción con confianza. ¿Listo para el siguiente nivel? Integra permisos por recurso, colas y trazas con OpenTelemetry para una plataforma robusta a escala.