Drizzle ORM with NestJS and PostgreSQL in 2026: A Production-Ready Setup

TL;DR — This guide wires NestJS → DatabaseService → postgres.js → Drizzle ORM → PostgreSQL with type-safe schemas, Drizzle Kit migrations, connection pooling, graceful shutdown, and
process.envfor config. No@nestjs/config. No magic globals. Just a setup you can ship and scale.
If you're building a serious NestJS backend in 2026, choosing the database layer early matters more than it seems.
You don't just need an ORM that can create tables and run queries. You want something that is:
| Requirement | Why it matters |
|---|---|
| Type-safe | Catch schema/query bugs at compile time |
| Fast | Low overhead at runtime |
| Migration-friendly | Version-controlled schema changes |
| Testable | Easy to mock via NestJS DI |
| Production-ready | Pooling, timeouts, graceful shutdown |
| Simple config | Read env vars directly — no extra abstraction |
For my NestJS projects, one setup that checks those boxes particularly well is Drizzle ORM + PostgreSQL.
In this guide, we'll build a clean integration using pnpm, PostgreSQL, Drizzle ORM, and postgres.js — with configuration loaded straight from process.env.
What you'll learn#
- How to structure a scalable
core/databaselayer in NestJS - How to connect Drizzle to PostgreSQL via
postgres.js - How to generate and apply migrations with Drizzle Kit
- How to read
DATABASE_URLdirectly fromprocess.env(no@nestjs/config) - How to add health checks, graceful shutdown, and a production-ready folder layout
Final stack#
| Layer | Tool |
|---|---|
| Framework | NestJS |
| ORM | Drizzle ORM |
| Driver | postgres.js |
| Database | PostgreSQL |
| Migrations | Drizzle Kit |
| Package manager | pnpm |
| Config | process.env + .env |
The goal isn't just to get a database connection working — it's to create a database foundation you won't have to completely rewrite six months later.
Why Drizzle ORM with NestJS?#
NestJS doesn't force you to use a particular ORM.
That's actually a good thing.
NestJS is database-agnostic and can work with general-purpose database libraries and ORMs through its dependency injection system.
Drizzle fits naturally into this model.
Instead of hiding the database behind a huge abstraction layer, Drizzle keeps your SQL concepts relatively close to the application while providing strong TypeScript inference.
You define your schema in TypeScript:
import {
pgTable,
uuid,
varchar,
timestamp,
} from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', {
length: 255,
}).notNull().unique(),
name: varchar('name', {
length: 100,
}).notNull(),
createdAt: timestamp('created_at', {
withTimezone: true,
})
.defaultNow()
.notNull(),
});And Drizzle turns that schema into SQL migrations and strongly typed queries.
That's the part I really like.
You get TypeScript's developer experience without completely forgetting that PostgreSQL exists.
What we're building#
Our final architecture will look like this:
NestJS
│
▼
DatabaseService
│
▼
postgres.js
│
▼
Drizzle ORM
│
▼
PostgreSQLAnd the migration workflow:
Drizzle Schema
│
▼
Drizzle Kit
│
▼
SQL Migration
│
▼
PostgreSQLThis keeps application runtime concerns separate from migration tooling.
1. Create the NestJS project#
If you're starting from scratch:
pnpm dlx @nestjs/cli new my-apiChoose the options you want for your project.
For a modern TypeScript backend, I prefer ESM for new projects unless you have a dependency that specifically requires CommonJS.
Once the project is created:
cd my-api2. Install Drizzle and PostgreSQL#
For this setup, we'll use postgres.js as the PostgreSQL driver.
Install the runtime packages:
pnpm add drizzle-orm postgresThen install Drizzle Kit:
pnpm add -D drizzle-kitDrizzle's current PostgreSQL documentation supports postgres.js as one of its PostgreSQL drivers and provides the same general schema/configuration/migration workflow used here.
You don't need pg for this particular setup.
3. Add PostgreSQL configuration#
Create a .env file:
NODE_ENV=development
PORT=4000
DATABASE_URL=postgresql://postgres:password@localhost:5432/my_apiFor production, use your actual managed PostgreSQL or server connection string.
Don't commit .env.
Your repository should contain:
.env
.env.exampleThe .env.example can contain:
NODE_ENV=development
PORT=4000
DATABASE_URL=4. Create the database structure#
I like keeping infrastructure concerns inside a core directory in NestJS applications.
src/
├── core/
│ ├── config/
│ │ └── env.ts
│ ├── database/
│ │ ├── database.module.ts
│ │ ├── database.service.ts
│ │ ├── database.types.ts
│ │ ├── schema/
│ │ │ └── index.ts
│ │ └── relations/
│ │ └── index.ts
│ └── health/
│
├── modules/
│ ├── users/
│ └── auth/
│
├── app.module.ts
└── main.tsThe important distinction is:
core/database
↓
Technical database infrastructure
modules/users
↓
Business logicYour database connection shouldn't become a business module.
5. Configure Drizzle Kit#
Create this file at the project root:
drizzle.config.tsUse:
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/core/database/schema/*.schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
strict: true,
verbose: true,
});Drizzle's current configuration documentation uses drizzle.config.ts to define the PostgreSQL dialect, schema location, migration output directory, and database credentials.
Notice that we're loading .env directly here:
import 'dotenv/config';That's intentional.
drizzle.config.ts is executed by Drizzle Kit, not by NestJS.
Your Nest application can have its own configuration system.
6. Create your first schema#
Create:
src/core/database/schema/users.schema.tsimport {
pgTable,
uuid,
varchar,
timestamp,
} from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id')
.defaultRandom()
.primaryKey(),
email: varchar('email', {
length: 255,
})
.notNull()
.unique(),
name: varchar('name', {
length: 100,
}).notNull(),
createdAt: timestamp('created_at', {
withTimezone: true,
})
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', {
withTimezone: true,
})
.defaultNow()
.notNull(),
});Then export it:
// src/core/database/schema/index.ts
export * from './users.schema.js';Because we're using ESM, notice the .js extension in the import.
7. Create the Drizzle database type#
Create:
src/core/database/database.types.tsimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import * as schema from './schema/index.js';
export type AppDatabase =
PostgresJsDatabase<typeof schema>;This gives your Nest services a fully typed Drizzle database instance.
8. Create the DatabaseService#
Now we connect PostgreSQL to Drizzle.
Create:
src/core/database/database.service.tsimport {
Injectable,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema/index.js';
import type { AppDatabase } from './database.types.js';
function getDatabaseUrl(): string {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error(
'DATABASE_URL is not set. Add it to your .env file.',
);
}
return url;
}
@Injectable()
export class DatabaseService
implements OnModuleInit, OnModuleDestroy
{
private readonly client: postgres.Sql;
readonly db: AppDatabase;
constructor() {
const databaseUrl = getDatabaseUrl();
this.client = postgres(databaseUrl, {
max: 20,
idle_timeout: 30,
connect_timeout: 10,
});
this.db = drizzle({
client: this.client,
schema,
});
}
async onModuleInit(): Promise<void> {
await this.client`SELECT 1`;
console.log('Database connected ✅');
}
async onModuleDestroy(): Promise<void> {
await this.client.end();
console.log('Database connection closed.');
}
}The important part is:
this.client = postgres(databaseUrl);
this.db = drizzle({
client: this.client,
schema,
});Drizzle's current PostgreSQL documentation explicitly supports creating a postgres.js client and passing it into Drizzle.
9. Why use a DatabaseService?#
You could technically do this:
const db = drizzle(...);in a random file and import it everywhere.
I wouldn't recommend that in a NestJS application.
Nest has dependency injection for a reason.
Instead:
NestJS
↓
DatabaseService
↓
DrizzleYour services can then simply request:
constructor(
private readonly database: DatabaseService,
) {}This makes the database easier to:
- Mock in tests
- Replace later
- Configure
- Monitor
- Shut down gracefully
- Extend with transactions
- Extend with tenant context
- Manage in one place
Nest supports custom providers and application lifecycle hooks for managing external resources such as database connections.
10. Create DatabaseModule#
Create:
src/core/database/database.module.tsimport { Global, Module } from '@nestjs/common';
import { DatabaseService } from './database.service.js';
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}The @Global() decorator means you don't need to import DatabaseModule into every feature module.
Import it once in AppModule.
11. Add it to AppModule#
import { Module } from '@nestjs/common';
import { DatabaseModule } from './core/database/database.module.js';
@Module({
imports: [DatabaseModule],
})
export class AppModule {}No ConfigModule. No ConfigService. The database layer reads env vars directly.
12. Load environment variables with process.env#
We keep configuration simple: read from process.env and fail fast when required values are missing.
Load .env in main.ts#
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
const port = Number(process.env.PORT) || 4000;
await app.listen(port);
}
bootstrap();Install dotenv if you haven't already:
pnpm add dotenvNote: Drizzle Kit already loads
.envviaimport 'dotenv/config'indrizzle.config.ts. Your Nest app does the same inmain.ts. One source of truth, no extra config package.
Optional: centralize env access#
If you want a single place to validate env vars, create a small helper — still using process.env directly underneath:
src/core/config/env.tsfunction requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(
`Missing required environment variable: ${name}`,
);
}
return value;
}
export const env = {
nodeEnv: process.env.NODE_ENV ?? 'development',
port: Number(process.env.PORT) || 4000,
databaseUrl: requireEnv('DATABASE_URL'),
} as const;Then in DatabaseService:
import { env } from '../config/env.js';
const databaseUrl = env.databaseUrl;This gives you a clean, explicit configuration pipeline:
.env
↓
dotenv/config (main.ts)
↓
process.env
↓
DatabaseService
↓
postgres.js
↓
Drizzle ORM
↓
PostgreSQLNo @nestjs/config. No nested config keys. Just environment variables — the way most production Node.js apps actually work.
13. Generate your first migration#
Add these scripts to package.json:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:check": "drizzle-kit check"
}
}Now run:
pnpm db:generateThen:
pnpm db:migrateDrizzle Kit generates migration SQL from your TypeScript schema, while migrate applies those migrations to PostgreSQL.
You'll end up with something like:
drizzle/
├── 0000_initial.sql
└── meta/Commit these migration files to Git.
14. push vs generate#
This distinction is important.
For rapid local experimentation:
pnpm db:pushDrizzle documents push as a convenient way to quickly synchronize schema changes with a development database without managing migration files manually.
For a production application:
pnpm db:generate
pnpm db:migrateI recommend the migration workflow once your schema becomes important.
A good workflow is:
Development
↓
Change schema
↓
pnpm db:push
↓
Test
↓
Finalize schema
↓
pnpm db:generate
↓
Commit migration
↓
Production
↓
pnpm db:migrate15. Query Drizzle from a NestJS service#
For example:
src/modules/users/users.service.tsimport { Injectable } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DatabaseService } from '../../core/database/database.service.js';
import { users } from '../../core/database/schema/users.schema.js';
@Injectable()
export class UsersService {
constructor(
private readonly database: DatabaseService,
) {}
async findAll() {
return this.database.db
.select()
.from(users);
}
async findById(id: string) {
const [user] =
await this.database.db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
return user ?? null;
}
}That's the beauty of the setup.
Your feature doesn't know how PostgreSQL connections are created.
It only knows:
this.database.db16. Add relations as your application grows#
Don't put every relation into one giant schema file.
For a larger application:
database/
├── schema/
│ ├── users.schema.ts
│ ├── gyms.schema.ts
│ ├── branches.schema.ts
│ ├── memberships.schema.ts
│ └── attendance.schema.ts
│
└── relations/
├── users.relations.ts
├── gyms.relations.ts
├── memberships.relations.ts
└── index.tsFor example:
export const usersRelations = relations(
users,
({ many }) => ({
memberships: many(memberships),
}),
);This becomes especially useful once your application has multiple related business domains.
17. Keep schema and business logic separate#
This is one architectural rule I strongly recommend.
Don't do this:
users/
├── users.schema.ts
├── users.service.ts
└── users.controller.tsif you're deliberately treating the database as infrastructure.
Instead:
core/
└── database/
└── schema/
└── users.schema.ts
modules/
└── users/
├── users.controller.ts
├── users.service.ts
└── users.module.tsThe difference is:
Schema
= How data is stored
Service
= What the application does with that dataThat separation becomes extremely valuable in larger systems.
18. Graceful database shutdown#
We already implemented:
async onModuleDestroy() {
await this.client.end();
}But make sure Nest receives shutdown signals.
In main.ts:
const app =
await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(port);Nest's lifecycle system provides shutdown hooks specifically for cleanup work such as closing connections gracefully.
19. Add database readiness checks#
You should distinguish between:
/healthand:
/health/ready/health#
Answers:
Is the application process alive?
It shouldn't require PostgreSQL.
/health/ready#
Answers:
Can this application actually serve requests?
Here you can check PostgreSQL:
await this.database.db.execute(
sql`SELECT 1`,
);So your infrastructure becomes:
GET /health
↓
Application alive
GET /health/ready
↓
Application alive
+
PostgreSQL reachableThis is much more useful when deploying to Docker, Kubernetes, Render, VPS infrastructure, or other production environments.
20. Production project structure#
After the project grows, I recommend something like:
src/
├── core/
│ ├── config/
│ │ └── env.ts
│ │
│ ├── database/
│ │ ├── database.module.ts
│ │ ├── database.service.ts
│ │ ├── database.types.ts
│ │ │
│ │ ├── schema/
│ │ │ ├── users.schema.ts
│ │ │ ├── gyms.schema.ts
│ │ │ ├── branches.schema.ts
│ │ │ ├── memberships.schema.ts
│ │ │ └── attendance.schema.ts
│ │ │
│ │ └── relations/
│ │ ├── users.relations.ts
│ │ ├── gyms.relations.ts
│ │ └── index.ts
│ │
│ ├── health/
│ └── observability/
│
├── common/
│ ├── decorators/
│ ├── guards/
│ ├── interceptors/
│ ├── filters/
│ └── pipes/
│
├── modules/
│ ├── auth/
│ ├── users/
│ ├── gyms/
│ ├── branches/
│ ├── memberships/
│ └── attendance/
│
├── app.module.ts
└── main.ts
drizzle/
├── 0000_initial.sql
├── 0001_add_gyms.sql
├── 0002_add_memberships.sql
└── meta/
drizzle.config.ts
.env
.env.example
package.jsonThis keeps the architecture understandable even when the project becomes large.
Production checklist#
Before calling the integration production-ready, I'd want these boxes checked:
- PostgreSQL
- Drizzle ORM
-
postgres.js - TypeScript types
- NestJS dependency injection
- Centralized
DatabaseService - Connection pooling
- Connection timeout
- Graceful shutdown
- Drizzle Kit
- Version-controlled migrations
- Separate schema files
- Separate relations
-
process.envconfiguration (no@nestjs/config) - Database readiness check
- Development
pushworkflow - Production
generate+migrateworkflow
And importantly, don't add complexity like RLS, AsyncLocalStorage, tenant-specific connections, or repository abstractions until the application's actual authorization and multi-tenant requirements justify them. Those are useful advanced tools, but "future-proof" doesn't mean adding every possible abstraction on day one.
The final stack#
NestJS
│
├── process.env + dotenv
│
├── DatabaseModule
│ │
│ └── DatabaseService
│ │
│ └── postgres.js
│ │
│ └── Drizzle ORM
│ │
│ └── PostgreSQL
│
├── HealthModule
│
└── Business Modules
│
├── Auth
├── Users
├── Gyms
├── Branches
├── Memberships
└── AttendanceThat's the setup I'd use as the foundation for a serious NestJS backend in 2026: Drizzle ORM + postgres.js + PostgreSQL, wired through NestJS dependency injection, configured with process.env, and migrated with Drizzle Kit. Simple, type-safe, and production-ready.