An opinionated NestJS 11 backend skeleton — PostgreSQL, Redis cache, BullMQ queues, strict validation, and a complete example feature — ready to fork into new services.
- Features
- Tech Stack
- Quick Start
- Configuration
- Project Structure
- Database
- Cache & Queues
- Observability
- Example: Articles CRUD
- Testing
- Conventions
- Spec-Driven Development
- npm Scripts
- Deployment
- License
- ⚡ Fastify HTTP adapter (not Express).
- 🗄️ PostgreSQL + TypeORM with entity-first, script-driven migrations & seeders.
- 🧊 Redis cache via
@nestjs/cache-manager, with an in-memory fallback for dev. - 🐂 BullMQ queue root connection on its own, separate Redis.
- 🛡️ Fail-fast config — every
*_*env var validated at boot withclass-validator. - 🧬 Strict DTOs —
whitelist+forbidNonWhitelisted+transformglobally. - 📡 Observability — structured pino logs (Loki-ready) with trace correlation, toggleable Prometheus metrics, and toggleable OpenTelemetry tracing.
- 🧱 Reference CRUD feature (
Articles) wiring all of the above together. - 📋 Spec-driven workflow via OpenSpec (
openspec/).
| Area | Choice |
|---|---|
| Runtime | Node.js 22+ (uses require() of ESM for @keyv/redis) |
| Framework | NestJS 11 |
| HTTP adapter | Fastify (@nestjs/platform-fastify) |
| Language | TypeScript (strict) |
| Database | PostgreSQL via TypeORM (@nestjs/typeorm + pg) |
| Migrations / Seeds | TypeORM CLI (entity-first) + in-house seeder runner |
| Cache | @nestjs/cache-manager + Redis (@keyv/redis), in-memory fallback |
| Queues | BullMQ (@nestjs/bullmq) on a dedicated Redis connection |
| Config | @nestjs/config + class-validator (fail-fast env validation) |
| Validation / mapping | class-validator + class-transformer |
| Tooling | ESLint (flat config), Prettier, Jest + Supertest |
Prerequisites: Node.js 22+, plus PostgreSQL and (optionally) Redis. The fastest way to get the datastores is Docker:
# PostgreSQL
docker run -d --name nest-pg \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=nest_boilerplate \
-p 5432:5432 postgres:16-alpine
# Redis (optional in dev — cache falls back to in-memory)
docker run -d --name nest-redis -p 6379:6379 redis:7-alpineThen, from the project root:
# 1. install dependencies
npm install
# 2. create your env file (defaults already point at localhost)
cp .env.example .env
# 3. create the schema and seed example data
npm run migrate
npm run db:seed
# 4. start in watch mode
npm run start:devVerify it's up:
curl http://localhost:3000/articles | jqPrefer a single docker-compose.yml?
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: nest_boilerplate
ports: ['5432:5432']
redis:
image: redis:7-alpine
ports: ['6379:6379']Run with docker compose up -d.
Configuration is entirely env-driven and validated at boot — a missing or invalid
variable aborts startup with a message naming the offending variable. Copy
.env.example to .env and adjust.
Full environment variable reference
| Variable | Default | Notes |
|---|---|---|
NODE_ENV |
development |
Also gates migrate:fresh (only local/development) |
PORT |
3000 |
HTTP port |
SERVICE_NAME |
nest-boilerplate |
Log prefix / app name |
| Variable | Default | Notes |
|---|---|---|
DB_HOST |
— | required |
DB_PORT |
5432 |
|
DB_USERNAME |
— | required |
DB_PASSWORD |
— | required |
DB_DATABASE |
— | required |
DB_POOL_MAX |
10 |
Max pool size |
DB_POOL_MIN |
1 |
Min idle connections |
DB_POOL_IDLE |
10000 |
Idle timeout (ms) |
DB_POOL_CONN_TIMEOUT |
10000 |
Acquire timeout (ms) |
| Variable | Default | Notes |
|---|---|---|
DB_REPLICATION_ENABLED |
false |
Master/slave read replicas |
DB_MASTER_HOST / DB_SLAVE_HOSTS |
— | Required when replication is on |
DB_PGBOUNCER |
false |
Transaction-pooling-safe settings |
DB_DIRECT_URL |
— | Direct URL so migrations bypass PgBouncer |
| Variable | Default | Notes |
|---|---|---|
CACHE_REDIS_ENABLED |
false |
false → in-memory (no Redis needed) |
CACHE_REDIS_HOST |
— | Required when enabled |
CACHE_REDIS_PORT |
6379 |
|
CACHE_REDIS_PASSWORD |
— | Optional |
CACHE_REDIS_DB |
0 |
|
CACHE_TTL |
60000 |
Default entry TTL (ms) |
| Variable | Default | Notes |
|---|---|---|
BULLMQ_REDIS_HOST |
— | required |
BULLMQ_REDIS_PORT |
6379 |
|
BULLMQ_REDIS_PASSWORD |
— | Optional |
BULLMQ_REDIS_DB |
0 |
|
BULLMQ_PREFIX |
bull |
Key prefix |
src/
main.ts # bootstrap: Fastify + global ValidationPipe + dotenv
app.module.ts # wires the global modules below
database/ # DatabaseModule, DataSource, migrations/, seeders/
cache/ # global CacheModule (Redis / in-memory)
bullmq/ # global BullMQ root connection
articles/ # EXAMPLE CRUD feature (reference — safe to delete)
openspec/
specs/ # source-of-truth capability specs
changes/ # in-flight & archived change proposals
PostgreSQL via TypeORM, initialized by the global DatabaseModule
(src/database/). synchronize is disabled — schema changes go through
migrations only.
Define or change an entity, then generate a migration by diffing against the DB:
| Command | What it does |
|---|---|
npm run migrate:generate -- src/database/migrations/<Name> |
Generate from entity diff |
npm run migrate:create -- src/database/migrations/<Name> |
Create an empty migration |
npm run migrate |
Apply pending migrations |
npm run migrate:revert |
Revert the last applied migration |
npm run migrate:show |
Show applied / pending status |
npm run migrate:fresh |
local/development) |
Entities keep camelCase properties mapped to snake_case columns explicitly:
@Column({ name: 'created_at' }) createdAt: Date;
Seeders live in src/database/seeders/. Implement the Seeder contract, register
your seeder in DatabaseSeeder (runs in order), then:
npm run db:seedThe cache and BullMQ each use their own, separate Redis connection — they must
not share one, because BullMQ uses blocking commands and requires
maxRetriesPerRequest: null. Both are global modules configured from validated env.
Cache (src/cache/) | BullMQ (src/bullmq/) |
|---|---|
|
// inject anywhere
@Inject(CACHE_MANAGER)
private readonly cache: Cache; |
Provides the BullMQ root connection only. Register queues per feature: BullModule.registerQueue({ name: 'emails' }); |
Three pillars, unified by a correlation id so a log line links to its trace:
- Logs → Loki. Structured JSON via
nestjs-pino, always on, written to stdout (a shipper like Grafana Alloy forwards them to Loki — the app never talks to Loki directly). Every request's logs share a correlation id; when tracing is on, that id is the OpenTelemetrytrace_id. - Metrics → Prometheus. A
/metricsendpoint (default process metrics + an HTTP request-duration histogram). Off by default — enable withMETRICS_ENABLED=true. - Traces → Tempo. OpenTelemetry auto-instrumentation (HTTP/Fastify,
pg,ioredis) exported via OTLP. Off by default — enable withOTEL_ENABLED=trueand setOTEL_EXPORTER_OTLP_ENDPOINT. The SDK is initialized before the app loads; when disabled it never starts (zero overhead).
| Variable | Default | Notes |
|---|---|---|
LOG_LEVEL |
info |
fatal|error|warn|info|debug|trace|silent |
METRICS_ENABLED |
false |
Exposes GET /metrics when true |
OTEL_ENABLED |
false |
Starts OpenTelemetry tracing when true |
OTEL_SERVICE_NAME |
nest-boilerplate |
Service name on traces/logs |
OTEL_EXPORTER_OTLP_ENDPOINT |
— | Required when OTEL_ENABLED=true |
A ready-to-run stack lives in deploy/observability/:
docker compose -f deploy/observability/docker-compose.observability.yml up -d
# then run the app with the pillars on:
METRICS_ENABLED=true OTEL_ENABLED=true \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 npm run start:devOpen Grafana at http://localhost:3001 (anonymous admin) — Loki, Prometheus,
and Tempo datasources are pre-provisioned, and a log's trace_id links straight
to its trace in Tempo. For local use only.
src/articles/ is a complete, self-contained reference feature — copy it as a
template, or delete it when forking (remove src/articles/, the ArticleSeeder
and its line in DatabaseSeeder, and the CreateArticles migration/table).
| Method | Path | Description |
|---|---|---|
POST |
/articles |
Create an article |
GET |
/articles?page=1&limit=20 |
Paginated list ({ data, meta }) |
GET |
/articles/:id |
Get one (read-through cached) |
PATCH |
/articles/:id |
Partial update (evicts cache) |
DELETE |
/articles/:id |
Delete (evicts cache) |
# create
curl -X POST http://localhost:3000/articles \
-H 'Content-Type: application/json' \
-d '{"title":"Hello","slug":"hello","content":"First post","published":true}'
# list (paginated)
curl 'http://localhost:3000/articles?page=1&limit=10'
# get one (cached for 30s; evicted on update/delete)
curl http://localhost:3000/articles/<id>Example responses
GET /articles/:id
{
"id": "1aa04c36-07e7-4115-936b-f71ad0d32b5a",
"title": "Hello",
"slug": "hello",
"content": "First post",
"published": true,
"publishedAt": null,
"createdAt": "2026-07-01T07:07:49.339Z",
"updatedAt": "2026-07-01T07:07:49.339Z"
}GET /articles (paginated envelope)
{
"data": [ /* ArticleResponseDto[] */ ],
"meta": { "total": 3, "page": 1, "limit": 10, "pageCount": 1 }
}Posting an unknown field is rejected by the strict ValidationPipe:
{ "statusCode": 400, "message": ["property isAdmin should not exist"], "error": "Bad Request" }What it demonstrates: entity → generated migration, a seeder, class-validator
DTOs, UpdateArticleDto via PartialType, pagination, response shaping with
plainToInstance (ArticleResponseDto), 404 handling, and read-through detail
caching with eviction on write.
npm run test # unit tests
npm run test:e2e # end-to-end tests
npm run test:cov # coverage- Fastify only — no Express-specific code or middleware.
- Official packages first — prefer an
@nestjs/*package when one exists. - Naming — class members are camelCase; entity columns are snake_case, mapped
explicitly (
@Column({ name: 'snake_case' })). - Validation & mapping — validate input with
class-validator; shape responses with a dedicated response DTO viaclass-transformer(plainToInstance), never by returning the entity directly.
Full details live in CLAUDE.md and
openspec/config.yaml.
Changes are planned as OpenSpec artifacts (proposal → specs → design → tasks)
before implementation. Current capabilities live in openspec/specs/; completed
changes are archived under openspec/changes/archive/. See CLAUDE.md for the
conventions AI assistants follow.
| Script | Purpose |
|---|---|
start / start:dev / start:prod |
Run the app (once / watch / compiled) |
build |
Compile to dist/ |
lint / format |
ESLint (--fix) / Prettier |
test / test:e2e / test:cov |
Unit / e2e / coverage |
migrate / migrate:generate / migrate:create |
Apply / generate / scaffold migrations |
migrate:revert / migrate:show / migrate:fresh |
Revert / status / rebuild |
db:seed |
Run the database seeders |
Review the NestJS deployment docs. Set
NODE_ENV appropriately (note migrate:fresh is refused outside
local/development), run npm run migrate on release, and point the cache and
BullMQ Redis connections at your managed instances (they can be separate).