Skip to content
ExponaBLOG
NESTJS · DAY 098 / 100

My NestJS starter template: patterns, pitfalls and fixes

What every new NestJS service gets on day one.

Expona Admin9 min read

After shipping NestJS services across e-commerce, SaaS, and fintech platforms, we've learned that the first 24 hours matter. The patterns you establish in day one either compound into clean, maintainable code or become technical debt you'll inherit for years. This article walks through the real template we use for every new NestJS service, covering the patterns, pitfalls, and fixes that turn a bare nest new project into production-ready infrastructure.

Foundation: Config, Logging, and Validation

Every NestJS service needs three things from the moment it boots: configuration that works across environments, structured logging you can actually search, and input validation that catches garbage before it hits your business logic.

Start with @nestjs/config and validate your schema at runtime. We don't rely on environment variable names alone—we validate types, ranges, and required fields the moment the app starts.

typescript
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  JWT_SECRET: z.string().min(32),
  REQUEST_TIMEOUT_MS: z.coerce.number().default(30000),
});

type Environment = z.infer;

@Module({
  imports: [
    ConfigModule.forRoot({
      validate: (config) => {
        const parsed = envSchema.safeParse(config);
        if (!parsed.success) {
          throw new Error(
            `Config validation failed: ${JSON.stringify(parsed.error.flatten())}`,
          );
        }
        return parsed.data;
      },
      isGlobal: true,
    }),
  ],
})
export class ConfigModule {}

For logging, we've moved away from the default NestJS logger to Pino. It's structured, fast, and integrates cleanly with log aggregation systems like DataDog or ELK. Configure it at bootstrap:

typescript
import { Logger } from 'nestjs-pino';
import { pinoHttp } from 'pino-http';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    logger: new Logger({
      pinoHttp: pinoHttp({
        level: process.env.LOG_LEVEL || 'info',
        transport:
          process.env.NODE_ENV === 'development'
            ? {
                target: 'pino-pretty',
                options: { colorize: true },
              }
            : undefined,
      }),
    }),
  });

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
      transformOptions: { enableImplicitConversion: true },
    }),
  );

  await app.listen(process.env.PORT || 3000);
}

The ValidationPipe with whitelist and forbidNonWhitelisted stops extra fields in request bodies cold. transform: true coerces string query parameters into actual numbers and booleans, saving you dozens of manual conversions.

Security: Helmet, CORS, Rate Limiting, and JWT Guard

Security isn't a feature—it's plumbing. We bolt on Helmet for headers, configure CORS tightly, throttle requests, and guard every endpoint with JWT by default.

First, the middleware stack:

typescript
import helmet from 'helmet';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';

const app = await NestFactory.create(AppModule);

app.use(helmet());

app.enableCors({
  origin: process.env.CORS_ORIGIN?.split(',') || 'localhost',
  credentials: true,
  maxAge: 3600,
});

app.useGlobalGuards(new ThrottlerGuard());

Then configure throttling in your module:

typescript
@Module({
  imports: [
    ThrottlerModule.forRoot([
      {
        ttl: 60000,
        limit: 100, // 100 requests per minute
      },
    ]),
  ],
})
export class AppModule {}

For JWT, we use a custom guard that requires authentication on every endpoint by default, then use a @Public() decorator for exceptions:

typescript
import { SetMetadata } from '@nestjs/common';

export const Public = () => SetMetadata('isPublic', true);

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  canActivate(context: ExecutionContext): boolean {
    const isPublic = this.reflector.getAllAndOverride('isPublic', [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    if (!token) {
      throw new UnauthorizedException();
    }

    try {
      const payload = this.jwtService.verify(token);
      request['user'] = payload;
      return true;
    } catch {
      throw new UnauthorizedException();
    }
  }

  private extractTokenFromHeader(request: any): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

Register it globally and mark your health and login endpoints as public:

typescript
@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
  ],
})
export class AppModule {}

@Controller('auth')
export class AuthController {
  @Post('login')
  @Public()
  login(@Body() dto: LoginDto) {
    // ...
  }
}

Data: Prisma, Migrations, and Seed Scripts

We've standardized on Prisma for ORM and schema management. It forces you to think about your database structure upfront and gives you type-safe queries in return.

Create a Prisma service that wraps the client and handles disconnection:

typescript
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }

  async enableShutdownHooks(app: INestApplication) {
    this.$on('beforeExit', async () => {
      await app.close();
    });
  }
}

Store migrations in version control and run them in your CI pipeline before every deployment. Your package.json should include:

typescript
{
  "scripts": {
    "prisma:migrate:dev": "prisma migrate dev",
    "prisma:migrate:deploy": "prisma migrate deploy",
    "prisma:seed": "ts-node prisma/seed.ts",
    "prisma:push": "prisma db push"
  }
}

And your CI runs npm run prisma:migrate:deploy before starting the app. For local development and test data, create a seed script:

typescript
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  const user = await prisma.user.upsert({
    where: { email: 'test@example.com' },
    update: {},
    create: {
      email: 'test@example.com',
      name: 'Test User',
      role: 'ADMIN',
    },
  });

  console.log('Seeded:', user);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

Operations: Health Checks, Graceful Shutdown, and Observability

Production services don't just run—they report their health, shut down cleanly, and emit telemetry. Kubernetes and load balancers need to know when your service is alive, and you need to know why it crashed.

Use Terminus for health checks:

typescript
import { TerminusModule, HealthCheckService, HttpHealthIndicator } from '@nestjs/terminus';

@Module({
  imports: [TerminusModule],
  controllers: [HealthController],
})
export class HealthModule {}

@Controller('health')
export class HealthController {
  constructor(private health: HealthCheckService) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () =>
        this.db.isHealthy('database', async () => {
          await prisma.$queryRaw`SELECT 1`;
        }),
    ]);
  }
}

For graceful shutdown, wire up signal handlers:

typescript
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const prisma = app.get(PrismaService);

  await prisma.enableShutdownHooks(app);

  process.on('SIGTERM', async () => {
    console.log('SIGTERM received, shutting down gracefully');
    await app.close();
  });

  await app.listen(3000);
}

For observability, add OpenTelemetry at bootstrap. We use auto-instrumentation to capture request spans without decorating every method:

typescript
import { NodeSDK } from '@opentelemetry/auto-instrumentations-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

Delivery: Docker, CI, and E2E Tests

We build services as Docker containers and test them end-to-end with a real Postgres database before they touch production.

Your Dockerfile should be multi-stage to keep images lean:

typescript
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
COPY prisma ./prisma

EXPOSE 3000
CMD ["node", "dist/main.js"]

Your CI pipeline should run migrations against a test database, execute your test suite, and build the image:

typescript
name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run prisma:migrate:deploy
      - run: npm run test:e2e
      - run: docker build -t app:latest .

For e2e tests, spin up a fresh database and hit your real endpoints:

typescript
describe('Users (e2e)', () => {
  let app: INestApplication;
  let prisma: PrismaService;

  beforeAll(async () => {
    const moduleFixture = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    prisma = app.get(PrismaService);
    await app.init();
  });

  afterAll(async () => {
    await prisma.user.deleteMany();
    await app.close();
  });

  it('GET /users returns all users', async () => {
    const user = await prisma.user.create({
      data: { email: 'test@example.com', name: 'Test' },
    });

    const response = await request(app.getHttpServer())
      .get('/users')
      .set('Authorization', `Bearer ${validToken}`);

    expect(response.status).toBe(200);
    expect(response.body).toContainEqual(
      expect.objectContaining({ id: user.id }),
    );
  });
});

Common Mistakes

  • Skipping config validation. Unvalidated environment variables lead to crashes hours after deployment. Validate at boot.
  • Global guards without @Public() decorator. You'll end up with authentication bypasses or endpoints locked behind JWT by accident. Establish a clear convention early.
  • Running migrations manually before deployment. Automate this in CI so every environment follows the same path. Manual steps are how state diverges.
  • No graceful shutdown. Kubernetes will send SIGTERM. If you don't handle it, in-flight requests die mid-transaction. Wire up shutdown hooks immediately.
  • Testing against mocks instead of real Postgres. Mock repositories feel fast but miss N+1 queries, migration bugs, and type mismatches. E2E tests with real databases save weeks of debugging.

Key Takeaways

  • Validate configuration at runtime with a schema validator like Zod; catch environment mistakes at boot, not runtime.
  • Use structured logging (Pino) and a global ValidationPipe; they're not nice-to-have, they're mandatory infrastructure.
  • Default to authenticated endpoints and use @Public() as an exception; this model scales better as your codebase grows.
  • Wrap Prisma in a service, automate migrations in CI, and seed test data with scripts; this pattern prevents state drift across environments.
  • Add health checks, graceful shutdown, and OpenTelemetry from day one; they're trivial to wire up now and invaluable in production.
  • Test end-to-end with real Postgres and Docker; this catches the gaps that unit tests miss and builds confidence before production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles