A production-ready, opinionated NestJS backend template with JWT authentication, role-based access control, structured logging, and SQLite persistence β ready to clone and extend.
- β¨ Features
- ποΈ Tech Stack
- π― Opinionated Decisions
- π Architecture
- β‘ Quickstart
- π³ Docker
- π Environment Variables
- π‘ API Reference
- π Authentication Flow
- π‘οΈ Role-Based Access Control
- π Logging
- π§ͺ Testing
- π€ Contributing
- π License
| Feature | Description |
|---|---|
| π JWT Auth | Access + refresh token rotation with bcrypt-hashed token storage |
| π‘οΈ RBAC | Decorator-driven role-based access control (admin, user) |
| ποΈ TypeORM + SQLite | Zero-config local persistence via SQLite; swap to Postgres/MySQL easily |
| β Validation | class-validator with strict password policy enforcement |
| π Structured Logging | File-based multi-channel logs (combined, error, stats) + HTTP interceptor |
| β€οΈ Health Checks | /health endpoint with live database connectivity probe |
| π WebSocket Ready | socket.io and @nestjs/websockets pre-installed and configured |
| π§ͺ Testing | Jest unit tests with mocked repositories; coverage reports included |
| π Timing-Attack Prevention | Constant-time hash comparison even for non-existent users |
| Layer | Technology |
|---|---|
| Framework | NestJS 11 |
| Language | TypeScript 5.7 |
| ORM | TypeORM 0.3 |
| Database | SQLite (swappable) |
| Auth | @nestjs/jwt + bcrypt |
| Validation | class-validator + class-transformer |
| WebSockets | Socket.io 4 |
| Testing | Jest 30 + Supertest |
| Linting | ESLint 9 (flat config) + typescript-eslint |
This template reflects specific architectural choices made to promote security, maintainability, and developer experience. Understanding these decisions will help you extend the template confidently.
Refresh tokens are never stored in plaintext. After generation, the raw token is hashed with bcrypt (cost factor 10) before persisting to the database. Only the hash is stored in user.refreshTokenHash.
Why? If your database is ever compromised, attackers cannot reuse refresh tokens. The raw token lives only in the HTTP response.
User logs in β raw tokens generated β refreshToken returned to client
β refreshTokenHash saved to DB
When a user provides an unknown username, the service still performs a bcrypt.compare() call against a dummy hash. This ensures login attempts for real and fake users take the same amount of time.
// auth.service.ts
const comparisonHash = user ? user.password : '$2b$12$invalidhashinvalidhas$2b$12$invalidhashinvalidhas';
const isMatch = await bcrypt.compare(loginUserDto.password, comparisonHash);Why? Without this, an attacker could measure response time to enumerate valid usernames.
Every call to PATCH /auth/refresh issues a brand new access + refresh token pair and invalidates the previous refresh token hash in the database. Single-use refresh tokens prevent token replay attacks.
ValidationPipe is configured globally with whitelist: true. Any property sent in a request body that is not declared in a DTO is silently stripped before it reaches your controller.
// main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Strip unknown properties
transform: true, // Auto-transform primitive types
stopAtFirstError: true // Return first error only
}));Registration enforces a strict password policy via class-validator's @IsStrongPassword:
- Minimum 12 characters
- At least 1 uppercase letter
- At least 1 lowercase letter
- At least 1 number
- At least 1 symbol
- Maximum 128 characters (prevents bcrypt DoS)
All user records use auto-generated UUID v4 primary keys instead of sequential integers. This prevents ID enumeration attacks and simplifies horizontal scaling.
Logging writes to three files in the logs/ directory with zero external dependencies (no Elasticsearch, no Datadog). This keeps the template self-contained while providing enough structure to pipe into any logging platform.
CORS is currently open to all origins (origin: true). This is intentional for a development template. Before deploying to production, restrict this to your trusted origins:
// main.ts β update before production
app.enableCors({
origin: ['https://your-frontend.com'],
credentials: true,
});src/
βββ main.ts # Bootstrap: logger, pipes, CORS, port
βββ modules/
βββ app.module.ts # Root module (imports all feature modules)
βββ auth/ # Authentication (login, register, refresh)
β βββ auth.controller.ts
β βββ auth.service.ts
β βββ auth.module.ts
β βββ guard/
β β βββ jwt-auth.guard.ts # Validates Bearer tokens on protected routes
β β βββ body-required.guard.ts
β βββ dto/
β βββ CreateUser.dto.ts # Strict password validation rules
β βββ loginUser.dto.ts
β βββ refreshUserTokens.dto.ts
βββ users/ # User management (CRUD, role-protected)
β βββ users.controller.ts
β βββ users.module.ts
βββ roles/ # Role management module
β βββ roles.controller.ts
β βββ roles.service.ts
β βββ roles.module.ts
βββ db/ # Database abstraction (TypeORM operations)
β βββ db.service.ts
β βββ db.module.ts
β βββ db.service.spec.ts
β βββ utils/
β βββ userConversion.ts
βββ jwt/ # JWT token generation & validation
β βββ jwt.service.ts
β βββ jwt.module.ts
βββ common/ # Shared utilities, guards, entities, logging
βββ entities/
β βββ user.entity.ts # TypeORM User entity (UUID, unique username)
βββ flow/
β βββ roles.guard.ts # RBAC guard (checks @Roles() decorator)
β βββ roles.decorator.ts # @Roles('admin') decorator
βββ health/
β βββ controller/
β βββ health.controller.ts
βββ logging/
β βββ services/
β β βββ logger.service.ts
β βββ interceptors/
β βββ logging.interceptor.ts
βββ utils/
βββ roleChecker.ts
βββ userRole.enum.ts
βββββββββββββββββββββββββββββββββββββββββββ
β User β
ββββββββββββββββ¬βββββββββββββββββββββββββββ€
β id β UUID (PK, auto-generated) β
β username β VARCHAR(64) UNIQUE β
β password β VARCHAR (bcrypt hash) β
β role β VARCHAR (default: 'user') β
β refreshTokenHash β VARCHAR (bcrypt hash, nullable) β
β createdAt β TIMESTAMP (auto-set) β
ββββββββββββββββ΄βββββββββββββββββββββββββββ
HTTP Request
β
βΌ
LoggingInterceptor β logs method, url, duration, userId
β
βΌ
JwtAuthGuard β validates Bearer token (protected routes)
β
βΌ
RolesGuard β checks @Roles() decorator (admin routes)
β
βΌ
ValidationPipe β strips unknown props, validates DTO
β
βΌ
Controller β Service β DbService β SQLite
β
βΌ
HTTP Response (+ logged by interceptor)
- Node.js β₯ 18 (download)
- npm β₯ 9
git clone https://github.com/DiamondJdev/NestJSTemplate.git my-app
cd my-app
npm installcp .env.example .envEdit .env with your secrets (see Environment Variables below).
Note: A
.env.examplefile is provided as a template. The app will start on port5200by default ifPORTis not set.
npm run start:devThe dev server starts at http://localhost:8080 (the --port 8080 flag in start:dev overrides the default). In production (npm start / npm run start:prod), the server listens on the port defined by PORT in your .env file, which defaults to 5200.
curl http://localhost:8080/
# β {"message":"OK","version":"1.0.0","database":{"status":"connected","latency":12},"backend":{"status":"running","uptime":123456},"timestamp":"2025-01-01T00:00:00.000Z","mode":"development"}curl -X POST http://localhost:8080/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "adminuser",
"password": "MyStr0ng!Password"
}'Example Response:
{
"message": "User registered successfully",
"userID": "550e8400-e29b-41d4-a716-446655440000",
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}curl http://localhost:8080/auth/me \
-H "Authorization: Bearer <your-access-token>"Prerequisites: Docker Desktop (or Docker Engine + Compose v2).
-
Copy the env template and fill in secrets:
cp .env.example .env
The stack reads
ALLOWED_ORIGINS,JWT_SECRET,TOKEN_ENCRYPTION_KEY, and theUPSTASH_*keys from.env; without them the API crashes on boot. Compose overridesDATABASE_URL/PORTso the container always uses the bundled Postgres on port 5200. -
Start the dev stack (API + Postgres):
docker compose up --build
- API: http://localhost:5200
- Swagger UI: http://localhost:5200/
- Health check: http://localhost:5200/health
- Postgres: localhost:5432 (
template_dev; a siblingtemplate_testDB is created automatically on first boot for the e2e suite)
Editing files under
src/hot-reloads the running container. Note: the file watcher detects the change and triggers a rebuild, but the restarted process can fail to bind port 5200 if the previous process hasn't released it yet (no graceful shutdown handling in this template) β the old process then keeps serving stale code. If your changes don't seem to take effect, rundocker compose restart api. -
Stop the stack (Postgres data persists in the
postgres_datavolume):docker compose down
Build the lean production image from the multi-stage Dockerfile's prod target:
docker build --target prod -t template-backend:prod .It compiles to dist/, installs production-only dependencies, and runs
node dist/src/main. Supply DATABASE_URL, ALLOWED_ORIGINS, and other env vars at
runtime via your orchestrator.
Create a .env file in the project root. The application reads all config via @nestjs/config.
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
No | 5200 |
HTTP server port |
JWT_SECRET |
Yes | β | Secret key for signing access tokens |
JWT_EXPIRATION |
No | 15m |
Access token TTL (e.g., 15m, 1h) |
JWT_REFRESH_EXP |
No | 7d |
Refresh token TTL (e.g., 7d, 30d) |
NODE_ENV |
No | development |
Set to production to suppress console logs |
.env.example:
PORT=5200
# Generate a strong secret: openssl rand -hex 64
JWT_SECRET=your-super-secret-key-change-me-in-production
JWT_EXPIRATION=15m
JWT_REFRESH_EXP=7d
NODE_ENV=development
β οΈ Never commit your.envfile. It is already included in.gitignore.
Base URL (development β npm run start:dev): http://localhost:8080
For production or when running npm start, the base URL is http://localhost:5200 (or http://localhost:${PORT} if you change the PORT value).
| Method | Endpoint | Auth | Body | Description |
|---|---|---|---|---|
POST |
/auth/register |
None | { username, password } |
Create a new account |
POST |
/auth/login |
None | { username, password } |
Authenticate and receive tokens |
PATCH |
/auth/refresh |
Bearer (access) | { refreshToken } |
Rotate access + refresh tokens |
GET |
/auth/me |
Bearer (access) | β | Check if the current token is valid |
| Method | Endpoint | Auth | Roles | Description |
|---|---|---|---|---|
GET |
/users |
Bearer | admin |
List all users |
GET |
/users/me |
Bearer | Any | Get current user's details |
PATCH |
/users/:uuid |
Bearer | Any* | Update user by UUID |
DELETE |
/users/:uuid |
Bearer | admin |
Delete user by UUID |
*Users can only update their own profile; admins can update any profile.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/ |
None | Returns server status and DB connectivity |
Health response example:
{
"status": "ok",
"database": {
"status": "connected",
"latencyMs": 12
},
"backend": {
"uptimeSeconds": 12345,
"version": "1.0.0",
"mode": "production"
},
"timestamp": "2025-01-01T12:00:00.000Z"
}POST /auth/login
Request:
{
"username": "myuser",
"password": "MyStr0ng!Password"
}Success (200):
{
"message": "User logged in successfully",
"userID": "550e8400-e29b-41d4-a716-446655440000",
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Failure (401):
{
"statusCode": 401,
"message": "Invalid Username or Password"
}PATCH /auth/refresh
Request (with Authorization: Bearer <accessToken> header):
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Success (200):
{
"message": "Token refreshed successfully",
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}This template uses a dual-token authentication strategy:
ββββββββββββ ββββββββββββββββ ββββββββββββββββββ
β Client β β NestJS API β β Database β
ββββββββββββ ββββββββββββββββ ββββββββββββββββββ
β β β
β POST /auth/login β β
ββββββββββββββββββββββββΆβ β
β β findOne(username) β
β βββββββββββββββββββββββββββΆβ
β ββββββββββββββββββββββββββββ
β β bcrypt.compare() β
β β rotateTokens() β
β β saveRefreshToken(hash) β
β βββββββββββββββββββββββββββΆβ
β { accessToken, β β
β refreshToken } β β
βββββββββββββββββββββββββ β
β β β
β (use accessToken β β
β for API calls) β β
β β β
β PATCH /auth/refresh β β
β Bearer: accessToken β β
β body: refreshToken β β
ββββββββββββββββββββββββΆβ β
β β compare(token, hash) β
β βββββββββββββββββββββββββββΆβ
β β rotateTokens() β
β β saveNewHash() β
β βββββββββββββββββββββββββββΆβ
β { new accessToken, β β
β new refreshToken } β β
βββββββββββββββββββββββββ β
Token Details:
| Token | Algorithm | Default Expiry | Storage |
|---|---|---|---|
| Access Token | HS256 | 15 minutes | Client memory / header |
| Refresh Token | HS256 | 7 days | Client (secure cookie / local) |
| Refresh Token Hash | bcrypt (cost 10) | β | Database (refreshTokenHash column) |
The template ships with two roles defined in userRole.enum.ts:
enum UserRole {
ADMIN = 'admin',
USER = 'user',
}Roles are enforced using a custom decorator + guard combination:
// Apply to any controller or route handler
@Roles('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Get()
findAll() { ... }How it works:
JwtAuthGuardvalidates the Bearer token and attachesreq.user(withidandrole).RolesGuardreads the@Roles()metadata and compares it againstreq.user.role.- If the role does not match, a
403 Forbiddenresponse is returned.
Default Role Assignment:
All newly registered users are assigned the user role. Admin accounts must be promoted manually (via direct DB update or a dedicated admin endpoint that you add to your project).
The template includes a zero-dependency, file-based logging system.
All files are written to the logs/ directory (auto-created, excluded from git):
| File | Contents |
|---|---|
logs/combined.log |
All log levels |
logs/error.log |
Errors and warnings only |
logs/stats.log |
User activity events (login, registration, etc.) |
2026-02-11T17:50:00.123Z [INFO][Bootstrap] Server running on http://0.0.0.0:5200
2026-02-11T17:50:01.456Z [INFO][HTTP] GET /users 200 {"duration":"12ms","userId":"abc-123"}
2026-02-11T17:50:02.789Z [ERROR][AuthService] Invalid credentials {"trace":"Error: ..."}
2026-02-11T17:50:03.012Z [INFO][UserStats] User abc-123 - login {"ip":"127.0.0.1"}
All incoming requests are automatically logged by LoggingInterceptor (applied globally). No manual configuration is needed. Each log entry includes:
- HTTP method and URL
- Response status code
- Response time (ms)
- User ID (if authenticated)
// Adjust the import path based on your file's location or configured path aliases
import { LoggerService } from 'src/modules/common/logging/services/logger.service';
@Injectable()
export class MyService {
constructor(private readonly logger: LoggerService) {}
doSomething() {
this.logger.log('Info message', 'MyService');
this.logger.warn('Warning message', 'MyService');
this.logger.error('Error message', error.stack, 'MyService');
// Log user actions to stats.log
this.logger.logUserStats('purchase', userId, { amount: 9.99 });
}
}See
LOGGING.mdfor the full logging documentation.
# Run all unit tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage report
npm run test:cov
# Run end-to-end tests
npm run test:e2eTests live alongside the source files (*.spec.ts):
src/modules/db/db.service.spec.ts # DbService unit tests
src/modules/common/health/controller/
health.controller.spec.ts # HealthController unit tests
Tests use Jest with @nestjs/testing and mocked TypeORM repositories β no real database is required to run the test suite.
Example test approach:
// Repositories are mocked with jest.fn() factories
const mockRepository = {
findOne: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
DbService,
{ provide: getRepositoryToken(User), useValue: mockRepository },
],
}).compile();SQLite is used for quick local setup. To swap to PostgreSQL:
- Install the driver:
npm install pg - Update the TypeORM config in
app.module.ts:
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5432'),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
autoLoadEntities: true,
synchronize: process.env.NODE_ENV !== 'production', // use migrations in prod
})Socket.io and @nestjs/websockets are pre-installed. To add a WebSocket gateway:
nest generate gateway eventsThis will scaffold a gateway class in src/events/ that you can extend with your real-time logic.
Contributions are welcome! Please follow these steps:
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Make your changes and add tests where appropriate
- Ensure all tests pass:
npm test - Lint your code:
npm run lint - Open a Pull Request describing your changes
This project is licensed under the MIT License. See the LICENSE file for details.
Made with β€οΈ by Cameron Ginther, Chloe Hunter, and Brett Berry