Production-ready NestJS REST API template. Feature-module architecture with light DDD layering, CQRS command/query/event buses, RBAC via CASL, cookie/JWT hybrid authentication, and a YAML-first configuration system — built to be cloned and extended, not just demoed.
- Feature summary
- Tech stack
- Architecture
- Project structure
- Quick start
- Configuration
- Modules
- Authentication & security
- Background jobs
- i18n
- Observability
- Database & migrations
- Testing
- Deployment
- Dependencies
- Roadmap
- NestJS 11 on Fastify
- Drizzle ORM over PostgreSQL, SQL-first migrations
- CQRS via
@nestjs/cqrs— controllers only dispatch commands/queries - RBAC with CASL, permissions seeded per role, checked via
@Policy()+PoliciesGuard - Cookie-based JWT auth (access + refresh), CSRF double-submit protection, brute-force lockout, passwordless magic-link login, password reset/change
- i18n-aware error responses and validation messages (
ru/en) - File storage via any S3-compatible backend (MinIO locally)
- Mail delivery via Handlebars templates + BullMQ queue, with retry
- In-app notifications, dispatched asynchronously
- Custom captcha module: SVG generation, Redis-backed pre-generated pool, admin authoring UI endpoints
- Admin module: dashboard stats, access-log audit trail, system settings
- Health checks (
@nestjs/terminus), request IDs, structured Pino logs, optional Graylog/Elasticsearch/Zabbix - Field-level AES-256-GCM encryption service for sensitive columns
- Docker Compose for local infra (Postgres, Redis, MinIO, Mailpit)
| Concern | Choice |
|---|---|
| Framework | NestJS 11 + Fastify adapter |
| Database / ORM | PostgreSQL + Drizzle ORM (drizzle-orm, drizzle-kit) |
| App-level pattern | CQRS (@nestjs/cqrs) + feature modules with DDD-style layers |
| Auth | @nestjs/jwt, @nestjs/passport (JWT strategy), argon2 hashing |
| Authorization | @casl/ability (RBAC, permission-based) |
| Queues | BullMQ over Redis (@nestjs/bullmq) |
| Object storage | S3-compatible (nestjs-s3, @aws-sdk/client-s3) — MinIO locally |
@nestjs-modules/mailer + Handlebars templates |
|
| Logging | nestjs-pino (Pino), optional Graylog/Elasticsearch transports |
| Config | @nestjs/config + custom YAML loader with ${VAR} env substitution |
| Validation | class-validator / class-transformer |
| API docs | @nestjs/swagger, served at /api/docs |
| Tests | Jest (unit / integration / e2e, separate configs) |
| Package manager | pnpm (via Corepack) — see Dependencies |
This is an Nest CLI monorepo with two deployable apps and a libs/ layer shared between them:
apps/backend— the main REST API (Fastify, HTTP), everything except credential verification/token issuance.apps/auth-service— a standalone gRPC microservice that owns login, refresh-token rotation, logout, and password reset/change. It exists because Argon2id password verification is CPU-bound work: a load test showed a login surge pinning the single backend process at >1000% CPU and dragging every other endpoint's latency up 40-70% along with it. Isolating that work into its own process/container means a login flood degrades login, not the rest of the product.docker/docker-compose.yamlcaps it atcpus: 4.0for the same reason — without a quota, one noisy container can still starve CPU-scheduling for its neighbors even on a single Docker host.libs/— code genuinely shared by both apps (not a dumping ground for anything auth-adjacent):libs/contracts/auth(the gRPC.proto+ hand-written TS types + failure-reason enums),libs/contracts/users(RoleType, used both in JWT payloads and CASL),libs/auth(PasswordService/PasswordServiceAdapter, JWT payload shape,AuthMode),libs/database(the shared Drizzle schema foruser/user_role/role_permission/refresh/password_reset_token— each app opens its own connection pool against it),libs/common(IdType,IBaseEntity, the YAML config loader).
apps/backend's AuthController REST surface (/auth/login, /auth/refresh, ...) is unchanged for clients — internally, its CQRS handlers (LoginUserHandler etc.) are thin: they call AuthGatewayPort (an abstract class — the only thing they depend on for this), which is implemented by AuthGrpcGatewayAdapter, the single place in apps/backend that talks gRPC. The handler translates the structured {success, failureReason, ...} response back into the same domain events, HTTP exceptions and i18n keys the endpoint always produced, and sets cookies locally (gRPC can't carry a live Fastify reply, so cookie-writing stays on the HTTP side). apps/auth-service mirrors the same business logic (lockout bookkeeping, rehash-on-login, refresh rotation) against its own narrow AuthUser/RefreshTokenRecord domain types — it never touches profile fields (name, theme, notification preferences, ...), which stay owned by apps/backend.
Feature modules live under apps/backend/src/modules/* (users, file, mail, notification, captcha, admin, health, migration). Each non-trivial module is internally layered:
modules/<name>/
domain/ # plain TS entities, abstract repository classes, interfaces — no ORM/framework imports
application/ # CQRS commands, queries, handlers, domain events
infrastructure/ # Drizzle repository implementations, adapters, Passport strategies, queue processors
presentation/ # controllers, request/response DTOs, entity→DTO mappers
Rules that hold across the codebase:
- Controllers never contain business logic. They validate input (DTO +
ValidationPipe) and dispatch aCommandBus/QueryBuscall. All decision-making lives in a handler. - Nothing holds an infrastructure dependency (ORM, HTTP/gRPC client) directly — only an abstract class (the port), with exactly one adapter implementing it, bound in the module's
providers.UserRepository → UserRepositoryDrizzleandAuthGatewayPort → AuthGrpcGatewayAdapterare the same pattern; swapping an implementation later means writing a new adapter, not touching application code. - Cross-module communication goes through the
EventBus, not direct service calls. E.g.UserCreatedEvent(published inusers) is consumed independently bymail(welcome email) andnotification(welcome notification). - Authentication's HTTP surface still lives inside
UsersModule(presentation/controllers/auth.controller.ts) — tightly coupled to the user aggregate (login attempts, lockout state, roles) even though the CPU-heavy verification now runs inapps/auth-service. - Global cross-cutting pieces (guards, decorators, exceptions, interceptors, i18n, config, CASL ability factory) live directly under
apps/backend/src/*, not inside a module. CASL/RBAC in particular stays entirely inapps/backend— it's authorization checked on every protected request across every module, not an auth-service concern.
apps/backend/
src/
main.ts # Fastify bootstrap, Swagger, security headers, global pipes/filters
app.module.ts # root module wiring
config/ # YAML config loader (re-exports libs/common/configuration)
common/
drizzle/ # DB connection, schema (non-auth tables), migration module
crypto/ # AES-256-GCM field encryption service
decorators/ # @CurrentUserId, @CurrentRoleId, @CurrentRoleType, @Policy
guards/ # JwtAuthGuard, PoliciesGuard, CsrfGuard
factories/ # CaslAbilityFactory
i18n/ # translation files (ru/en) + i18n service
interceptors/, exceptions/ # response shaping, global exception filter
enums/, interfaces/ # shared enums/types — several are thin re-exports of libs/* (see below)
options/ # async module option factories (bullmq, mailer, s3, throttler, logger)
modules/
users/ # accounts, roles/permissions, auth HTTP surface (AuthGatewayPort → gRPC)
file/ # S3-backed file storage with versioning
mail/ # templated email delivery via queue (no HTTP surface)
notification/ # in-app notifications
captcha/ # SVG captcha challenges + admin authoring
admin/ # dashboard, access logs, system settings
health/ # liveness/readiness checks
migration/ # boot-time seeding (roles, admin user, templates)
scripts/ # migrate.cjs, load-config.cjs
apps/auth-service/
src/
main.ts # gRPC microservice bootstrap (Transport.GRPC, no HTTP surface)
app.module.ts
drizzle.provider.ts # own connection pool against libs/database's schema
modules/auth/
domain/ # AuthUser/RefreshTokenRecord/PasswordResetToken — narrow, auth-only fields
application/ # LoginCommand/Handler + 5 more, mirror apps/backend's former logic
infrastructure/ # Drizzle repos, TokenIssuerService (JWT), MailProducerService (BullMQ)
presentation/ # AuthGrpcController (@GrpcMethod, converts proto ↔ commands)
libs/
contracts/
auth/ # auth.proto + hand-written TS types + failure-reason enums
users/ # RoleType (shared JWT payload / CASL contract)
auth/ # PasswordService/PasswordServiceAdapter, JWT payload shape, AuthMode
database/ # shared Drizzle schema (user/user_role/role_permission/refresh/password_reset_token)
common/ # IdType, IBaseEntity, YAML config loader
drizzle/migrations/ # generated SQL migrations (schema is centrally owned/migrated by apps/backend)
docker/ # docker-compose.yaml, both Dockerfiles, .env.example, config.docker.yaml
test/ # e2e harness, integration tests, shared setup
config.yaml.example # documented local config template
config.production.yaml.example
config.test.yaml # used by unit/integration/e2e test runs
Requirements: Node ^22.17 or ^24, pnpm (via Corepack — corepack enable), Docker.
pnpm install
copy docker\.env.example docker\.env # cp on macOS/Linux
pnpm docker # postgres, redis, minio, mailpit, backend
pnpm drizzle:migrate # apply SQL migrationsThe API is now at http://localhost:3000/api/v1, Swagger UI at http://localhost:3000/api/docs.
On first boot the app self-seeds (see modules/migration/migration.module.ts, runs in onModuleInit):
- 4 roles:
admin,manager,user,public(modules/migration/configs/roles.config.ts), each with a fixed permission set - One admin user, built from
ADMIN_EMAIL/ADMIN_PASSWORDindocker/.env— password must satisfyisStrongPassword(8+ chars, upper/lower/number/symbol) or boot fails - Default notification and mail templates
Re-running seeding is idempotent — it only inserts when the corresponding table is empty.
For a bare-metal (non-Docker) dev loop:
copy config.yaml.example config.yaml # edit DB/Redis connection to point at local services
pnpm drizzle:migrate
pnpm start:devConfig is YAML-first, not raw process.env reads scattered through the code. apps/backend/src/config/configuration.ts loads a YAML file and substitutes ${VAR} placeholders from the environment before parsing:
${VAR}— replaced with the env var, or empty string if unset${VAR:-default}— falls back todefaultif unset${VAR:?message}— throws at boot if unset (used for every secret)
Which file loads is controlled by CONFIG_FILE (defaults to config.yaml, or config.test.yaml when NODE_ENV=test). Access values via ConfigService.get('jwt.access.token')-style dot paths — never process.env.X directly in application code.
| File | Used for |
|---|---|
config.yaml.example |
Documented template — copy to config.yaml for local, non-Docker dev |
config.production.yaml.example |
Documented template for a production deployment |
config.test.yaml |
Fixed config for unit/integration/e2e test runs (checked in) |
docker/config.docker.yaml |
Mounted into the backend container by docker-compose.yaml |
docker/.env.example |
Secrets/ports consumed by docker-compose.yaml and config.docker.yaml |
Key top-level config sections (see config.yaml.example for the full annotated list):
| Section | Controls |
|---|---|
host |
hostname/port/origin/environment |
database |
Postgres connection (or a single uri) |
redis |
Redis connection, backs BullMQ and the captcha pool |
jwt |
access/refresh secrets, algorithms, expiry |
auth |
mode (HYBRID/COOKIES_ONLY/RESPONSE_ONLY), cookie flags, CSRF toggle |
security |
encryptionKey for field-level AES-256-GCM encryption |
admin |
bootstrap admin email/password |
swagger |
enable + optional Basic Auth in front of /api/docs |
s3 |
object storage credentials/endpoint (optional — file uploads no-op without it) |
mailer |
SMTP credentials (optional — mail sending no-ops without it) |
throttle |
global rate-limit window/limit |
graylog |
optional GELF log shipping |
kibana |
optional Elasticsearch log shipping (via pino-elasticsearch) |
Owns the user, user_role, role_permission, refresh, password_reset_token, magic_link_token tables.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /auth/me |
JWT | Current authenticated user |
| POST | /auth/login |
— | Email/password login (rate-limited) |
| POST | /auth/refresh |
refresh cookie/body | Rotate access+refresh token pair |
| POST | /auth/logout |
refresh cookie/body | Revoke refresh token |
| POST | /auth/forgot-password |
— | Send password-reset email (rate-limited) |
| POST | /auth/reset-password |
— | Consume reset token, set new password |
| POST | /auth/change-password |
JWT | Change password (requires current one) |
| POST | /auth/magic-link/request |
— | Email a one-time passwordless login link |
| POST | /auth/magic-link/authenticate |
— | Exchange magic-link token for a session |
| CRUD | /users, /users/roles |
JWT + policy | User & role management |
See Authentication & security below for how login, lockout, tokens and RBAC actually work.
Owns the file (+ version) tables; storage itself lives in S3/MinIO, not the DB.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /file/users/me |
JWT | Multipart upload, one or more files |
| GET | /file/:fileId[/:versionId] |
JWT | Download a private file (ownership/role checked) |
| GET | /file/public/:fileId[/:versionId] |
— | Download a public file, no auth |
Files are versioned (file_version), streamed through the backend rather than redirecting to a signed S3 URL — see infrastructure/adapters/s3.adapter.ts.
No HTTP controller — it's an internal service used by other modules (auth flows, notifications) via CommandBus. Templates are Handlebars, stored in Postgres (not the filesystem) and seeded at boot from mail/infrastructure/services/template-seed.service.ts. Sending is asynchronous via a BullMQ mail queue (mail.processor.ts), with delivery status tracked per message and up to 5 retry attempts on failure.
The mail_template name enum already includes oauth-first-login and oauth-account-linked alongside the templates actually in use (welcome, reset-password, magic-link-login, …) — placeholders left for the third-party auth work in the roadmap, not yet sent by any handler.
In-app notifications for the current user, dispatched asynchronously via a BullMQ notifications queue.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /notifications |
JWT + policy | List current user's notifications |
| GET | /notifications/unread-count |
JWT + policy | Unread count |
| PATCH | /notifications/:id/read |
JWT + policy | Mark one as read |
| PATCH | /notifications/read-all |
JWT + policy | Mark all as read |
| DELETE | /notifications/:id |
JWT + policy | Delete a notification |
Home-grown SVG text captcha — no third-party captcha service dependency.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /captcha/challenges |
— | Issue a challenge from the pre-generated pool |
| GET | /captcha/challenges/:id/image |
— | Stream the challenge image |
| POST | /captcha/challenges/:id/verify |
— | Verify a submitted answer |
| CRUD | /admin/captcha/templates, /configs, /pools, /metrics |
JWT + policy | Author templates/configs, trigger batch pre-generation, inspect the Redis pool |
Design: challenge images are pre-generated in bulk into an S3-backed pool and tracked in Redis (RedisCaptchaPool), rather than generated synchronously per request — batch generation runs through a BullMQ captcha-generation queue/processor. Answers are hashed with HMAC (HmacCaptchaHashingService), never stored in plaintext. This is what the login lockout flow (see below) points users at once requiresCaptcha is returned.
Note: unlike roles/admin/mail templates, captcha templates are not seeded automatically. Challenge creation defaults to template code
svg-text-ru-v1and 404s if it doesn't exist and isn'tACTIVE. On a fresh environment, an admin must create it (and an active config) viaPOST /admin/captcha/templates+.../configs+.../activatebefore any captcha-gated flow works.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /admin/dashboard |
JWT + ADMIN_DASHBOARD |
System-wide stats |
| GET | /admin/access-logs |
JWT + ADMIN_ACCESS_LOG |
Paginated audit trail |
| GET | /admin/system-settings |
JWT + ADMIN_SETTINGS |
Read key/value system settings |
| PATCH | /admin/system-settings |
JWT + ADMIN_SETTINGS |
Upsert a system setting |
The access-log audit trail is populated by event handlers (admin/application/handlers/events/auth-audit.handlers.ts) reacting to UserLoginSucceededEvent/UserLoginFailedEvent/etc. published from the users module — admin doesn't know about login internals directly, it just listens.
| Method | Path | Checks |
|---|---|---|
| GET | /health |
DB ping + heap/RSS memory thresholds (@nestjs/terminus) |
| GET | /health/ready |
DB ping only |
| GET | /health/live |
Process liveness (no dependencies) |
Exempt from global rate limiting (@SkipThrottle()) and from the global /api/v1 prefix (see main.ts — internal/v1/* is excluded), so orchestrators can hit it predictably.
Not a REST module — an OnModuleInit hook that runs seeding (roles, permissions, admin user, notification/mail templates) every time the app boots. See Quick start.
- Token issuance/verification is centralized in
AuthServiceAdapter(modules/users/infrastructure/adapters/auth-service.adapter.ts) — access tokens (short-lived,jwt.access.*) and refresh tokens (long-lived, separate secret/algorithm,jwt.refresh.*) are signed independently. Every issued refresh token is persisted in therefreshtable with fingerprint + user-agent, so tokens can be revoked server-side (logout, or bulk-revoked on password change). - Three delivery modes (
auth.modeconfig):HYBRID(httpOnly cookies and tokens in the response body — default),COOKIES_ONLY(web-only, cookies only),RESPONSE_ONLY(body only, e.g. for native mobile clients that can't rely on cookies). - CSRF: double-submit cookie pattern (
CsrfGuard) — a non-httpOnlycsrf-tokencookie must match anX-CSRF-Tokenheader on unsafe methods, whenauth.csrf.enabledis on. A path allowlist (/auth/login,/auth/refresh,/auth/logout,/auth/magic-link/*) bypasses this for the endpoints that establish the session in the first place. - Brute-force protection:
LoginUserHandlertracksfailedLoginAttemptsin a rolling 15-minute window. 3rd failure setsrequiresCaptcha: truein the response (frontend should then require solving a/captcha/challengeschallenge); 5th failure locks the account for 30 minutes and emails an admin security alert. - Passwordless login: magic link (
magic_link_tokentable) — a signed one-time token emailed to an existing user, valid for a configurable TTL, bound to a request fingerprint. This is not a registration path; the user must already exist. - RBAC:
user_role+role_permissiontables define(roleType, action, subject)triples, seeded frommodules/migration/configs/roles.config.ts.CaslAbilityFactorybuilds a CASLAbilityper role (cached — seePoliciesService/clearCache()); routes are protected declaratively with@Policy(Actions.READ, Subjects.NOTIFICATION)+@UseGuards(JwtAuthGuard, PoliciesGuard). Unauthenticated requests are evaluated against thepublicrole rather than always rejected, so some endpoints can define public-tier policies. - Field-level encryption:
EncryptionService(common/crypto/encryption.service.ts) does AES-256-GCM for sensitive columns that need to be stored reversibly, plus a one-way SHA-256 mode for values that only need equality lookups (e.g. device tokens). Not currently wired into theusertable's own fields — available for any module that needs it. - Logs never leak secrets: the Pino redact list (
options/logger.module.options.ts) stripsAuthorization/Cookieheaders and anypassword/*Tokenfield from both requests and log messages before they reach any transport.
Three BullMQ queues, all backed by the same Redis instance (redis.* config):
| Queue | Producer | Consumer |
|---|---|---|
mail |
MailService (any module, via SendMailCommand) |
MailProcessor — renders template, sends via SMTP, tracks status, retries up to 5x |
notifications |
NotificationDispatchService |
NotificationDispatchProcessor — fans out to enabled channels |
captcha-generation |
Admin "generate batch" endpoint | CaptchaGenerationProcessor — pre-renders SVG challenges into the pool |
Translation files live under apps/backend/src/i18n/{ru,en}/*.json, split by namespace: common, user, notification, validation. Default language/locale/country come from settings.* in config; a request-scoped LanguageInterceptor resolves the active language per request. class-validator errors are translated by createValidationException (i18n/validation-exception.factory.ts) before reaching the client — validation failures are never raw English class-validator strings in production.
- Logging:
nestjs-pino, pretty-printed in dev, JSON in prod. Optional additional transports, toggled purely by config presence: file (log.file.*), Graylog/GELF (graylog.*), Elasticsearch (kibana.host, viapino-elasticsearch). - Health checks: see Health module above.
- Zabbix: no Zabbix client code exists in the app itself —
observability.zabbix.enabledis a config flag that makesmain.tslog a warning at boot when monitoring isn't wired up. The integration is a separate, optionaldocker/docker-compose.zabbix.yamlstack (zabbix-server/web/agent2) that attaches to the same Docker network and monitors the backend by tailing the same./logsvolume the backend writes to whenLOG_FILE_ENABLED=true. It is not started bypnpm dockerand must be brought up explicitly. - Request IDs: every response carries
X-Request-Id(respects an inboundx-request-idheader if it matches a safe pattern, otherwise generates one) — correlate a client-reported issue with server logs.
Schema is Drizzle, defined in apps/backend/src/common/drizzle/schema/*.schema.ts (one file per bounded area, barrelled in schema/index.ts). Domain entities are separate plain classes — nothing in domain/ imports Drizzle.
pnpm drizzle:generate # diff schema/*.schema.ts against drizzle/migrations, write new SQL
pnpm drizzle:migrate # apply pending migrations (apps/backend/scripts/migrate.cjs)
pnpm drizzle:migrate:test # same, against config.test.yamlMigrations are plain SQL files under drizzle/migrations/, applied via a custom runner (not drizzle-kit migrate) that tracks applied hashes in drizzle.__drizzle_migrations — this runs automatically as the first step of the Docker image's container command (see Deployment).
Known drift:
0000_initial.sql/0001_file_version_optional_s3_version.sqlare the only committed migrations, but the live schema (schema/*.schema.ts) has grown well beyond them (captcha_*, notification, mail, access_log, system_setting, role_permission, refresh, etc. — most of the schema isn't represented in a migration file). Runningpnpm drizzle:generateagainst the current schema will surface this as one large diff. The tables already exist in real deployments, most likely applied viadrizzle-kit pushrather than a generated migration. Regenerating migrations to match the schema should be treated as a separate, deliberate task rather than a side effect of unrelated changes.
Three independent Jest configs:
pnpm test / test:unit # colocated *.spec.ts next to source, mocked repositories — no DB/HTTP
pnpm test:unit:cov # same, with coverage
pnpm test:integration # NODE_ENV=test, real DB via config.test.yaml, --runInBand
pnpm test:e2e # full Nest app + Fastify + real DB, supertest agent
pnpm test:all # all three tiers in sequenceUnit tests mock repository/service abstract classes directly (jest.Mocked<T>), following the pattern in modules/users/application/handlers/*.spec.ts. The e2e harness (test/setup/app.ts) boots the real AppModule once and reuses it across specs; currently only a health-check e2e spec exists (test/e2e/app-health.e2e-spec.ts) — most coverage today is at the unit level.
test/integration/ has its own Jest config (NODE_ENV=test, real DB) but currently contains no spec files. pnpm test:integration — and therefore pnpm test:all, which chains it before test:e2e — fails with Jest's "No tests found, exiting with code 1" rather than passing trivially. Only one e2e spec exists. Both tiers are scaffolded but not populated; add a spec or pass --passWithNoTests before relying on test:all in CI.
One behavior worth knowing before running tests against a shared database: UserSeedService.seedIfEmpty() truncates user, role_permission, user_role, refresh whenever NODE_ENV=test, then reseeds — never point config.test.yaml at a database you care about.
pnpm test:e2e/test:integration connect to whatever config.test.yaml points at (localhost:5432/localhost:6380 by default) — that is not the same Postgres/Redis the Docker Compose stack exposes (docker/.env commonly remaps those to different host ports, e.g. 5440/6390). Point config.test.yaml at a real local Postgres/Redis (or temporarily re-map Compose's ports to match) before relying on these commands outside CI.
docker/docker-compose.yaml defines the full local/production-shaped stack:
| Service | Image | Purpose |
|---|---|---|
postgres |
postgres:17-alpine |
primary database |
redis |
redis:8-alpine |
BullMQ queues + captcha pool |
minio |
minio/minio |
S3-compatible object storage |
minio-init |
minio/mc |
one-shot bucket creation + versioning |
mailpit |
axllent/mailpit |
local SMTP catcher + web UI (dev/test only) |
auth-service |
built from docker/auth-service.Dockerfile |
gRPC-only credential/token service, capped at cpus: 4.0 |
backend |
built from docker/backend.Dockerfile |
the API itself, dials auth-service via gRPC (AUTH_SERVICE_GRPC_URL) |
Both images are two-stage builds (build → slim node:22-alpine runtime) and each copies libs/ into its runtime stage — the .proto contract and shared schema are read from disk at runtime, not compiled in. The backend container runs migrations before starting the server: node apps/backend/scripts/migrate.cjs && node dist/apps/backend/apps/backend/src/main.js — migrations apply automatically on every container start, so a fresh deploy never needs a manual migration step. auth-service has no migrations of its own — it connects to the same, already-migrated database.
Steps for a real deployment:
- Copy
config.production.yaml.example→ your production config; mount it whereverCONFIG_FILEpoints (seedocker-compose.yaml'sbackend.volumesfor the pattern). - Provide every
${VAR:?...}referenced in that file as a real secret — the app refuses to boot if any required one is missing (fail-fast by design, not a runtime surprise). - Set
host.environment: production,auth.cookies.*.secure: true,auth.cookies.*.sameSite: strict,auth.csrf.enabled: true. - Set real
swagger.user/swagger.password(Basic Auth in front of/api/docs) or disable Swagger entirely in production. - Point
s3.*at your real object storage andmailer.*at a real SMTP relay — both modules silently no-op without them, which is fine for a demo but not for production. docker compose --env-file docker/.env -f docker/docker-compose.yaml up -d(or run the built image directly against your own Postgres/Redis/S3).
Package management is pnpm (via Corepack, pinned in package.json#packageManager). Override and build-script configuration lives in pnpm-workspace.yaml: overrides forces specific versions for transitive dependencies (pnpm's equivalent of Yarn's resolutions), and allowBuilds is an explicit allowlist of packages permitted to run native/postinstall build scripts — a pnpm 11 security feature that silently skips unlisted packages.
Dependencies track their latest published version, with the following exceptions:
| Package | Pinned at | Reason |
|---|---|---|
sonic-boom |
^4.2.0 |
pino@10 depends on sonic-boom@^4.0.1 internally, and the custom GELF transport (utils/pino-gelf.mts) constructs SonicBoom directly; kept aligned with pino's own dependency rather than ahead of it. |
@swc/cli |
^0.7.10 |
Not used by the build or test pipeline (nest build compiles via tsc, tests run via ts-jest); the 0.8 line was not taken since 0.x version bumps are semver-breaking by convention. |
Two dependencies were removed rather than upgraded:
uuid— v12+ releases are ESM-only and do notrequire()from this project's CommonJS build output. The only usages (uuid.v4()for refresh-token JWT IDs and CSRF tokens, and theNILconstant) were replaced withnode:crypto'srandomUUID()and a literal string.@golevelup/ts-jest— declared inpackage.jsonbut not referenced anywhere in the codebase; removed.
Two upgrades required source changes beyond the version bump:
@casl/ability6→7 removed thePureAbilityexport in favor of a singleAbilityclass.CaslAbilityFactorybuilds onAbilityinstead; behavior is unchanged.- TypeScript 5→6 removed
baseUrl(path aliases already resolve relative to the config file without it), requires"ignoreDeprecations": "6.0"to keepesModuleInterop: false, and requires an explicitrootDiralongsideoutDirinapps/backend/tsconfig.app.json.
Two pre-existing defects, unrelated to any specific dependency version, were also identified and fixed: a phantom jsonwebtoken/ms dependency (imported in source but never declared in package.json, working previously only because Yarn's flat node_modules hoisted them), and an incorrect relative import path in mail/infrastructure/services/mail.service.ts that caused template-file-fallback emails (any template without DB-stored content, e.g. the welcome email) to fail silently.
- Third-party OAuth login (Google, Yandex — VK deferred pending its VK ID/OAuth2+PKCE migration) as an additional
apps/auth-serviceRPC + a new entry inAuthGatewayPort, alongside password login. - Magic-link authentication: the token-issuing "redeem" handler was removed when JWT signing moved into
apps/auth-service(it can no longer issue tokens locally). Requesting a magic link still works; redemption needs to be rebuilt as a 7th gRPC method following the sameAuthGatewayPortpattern as the other flows.