Most SaaS backends don't fail because they can't handle the traffic. They fail more slowly: a data model that can't separate customers cleanly, a codebase where every change touches everything, queries that were fine at 1,000 rows and aren't at 10 million, and a database that runs out of connections the day you add a second server.
NestJS and PostgreSQL are a very good pair for avoiding all of that. NestJS gives a TypeScript backend structure, dependency injection and conventions a team can share. PostgreSQL gives you transactions, constraints, row-level security, JSON and full-text search in one boring, reliable database. This guide is how I put them together for a SaaS product, in the order the decisions come up, based on leading backend engineering for two SaaS products on exactly this stack: APIs serving 10,000+ users a month at 99.9% uptime, backing more than $40K a month in revenue.
Each section gives you the decision and links to a deep dive where there's more to say.
TL;DR#
- Build a modular monolith. One NestJS app, one PostgreSQL database, one module per business capability with a small public API. Split services out later, if ever.
- Decide multi-tenancy on day one. A shared schema with
tenant_idon every table, enforced by PostgreSQL row-level security, suits most B2B SaaS. - Size connection pools on purpose. Total connections are instances × pool size; put a pooler in front once that number approaches the database's limit.
- Treat the schema like code: migrations reviewed in pull requests, run once per release, always backward-compatible.
- Measure queries before you scale anything.
pg_stat_statementsandEXPLAIN ANALYZEwill find more capacity than a bigger instance. - Push slow work to queues, keep the API stateless, and scale by adding instances behind a load balancer.
The architecture at a glance#
Clients (web app, mobile, integrations)
└─ Load balancer (TLS)
└─ NestJS API ×N stateless, horizontally scaled
├─ core: auth guards · request context · validation · errors · logging
├─ modules: customers · billing · invoices · notifications · …
└─ database: TypeORM · transactions · tenant context
└─ NestJS workers ×M same codebase, no HTTP: emails, PDFs, webhooks, syncs
├─ PostgreSQL primary (+ read replica for reporting, later)
│ └─ connection pooler PgBouncer or RDS Proxy, when connections get tight
└─ Redis queues (BullMQ), rate limits, cache
Nothing exotic. Every box is something a small team can run, understand at 3 a.m., and replace if it has to.
1. Start with a modular monolith#
The first architectural decision is the one teams most often get wrong in the "too much" direction. Microservices bring network calls where function calls used to be, distributed transactions, a deployment for every service, and a lot of YAML. Those are real costs, paid every day, for benefits that matter mostly to organisations with many teams.
A modular monolith gets you most of the benefits for a fraction of the cost. It's one deployable application, but internally it's split into NestJS modules along business lines, each owning its own tables and exposing a narrow API to the rest:
- Organise by feature, not by file type:
modules/billing/holds billing's controllers, services, entities and tests. - Keep repositories private and export only a service or two, so "who touches the invoices table?" has a short answer.
- Separate
core/(auth, request context, errors, logging) fromshared/(small generic helpers with no business rules). - Fix circular dependencies with events or better boundaries, not
forwardRef(). - Enforce the rules in CI with dependency-cruiser, so the structure survives deadlines.
NestJS project structure for large SaaS apps has the full folder layout, the module boundary rules and the CI config.
2. Design the data model for tenants#
In a B2B SaaS, every row belongs to a customer, and showing one customer another's data is the worst bug you can ship. How you separate tenants shapes your schema, your queries, your migrations and your backups, and it's very hard to change once you have customers.
You have three options in PostgreSQL: a database per tenant, a schema per tenant, or a shared schema with a tenant_id column. For most products, I recommend the third, done properly:
tenant_id NOT NULLon every tenant-owned table, leading every index.- Composite primary and foreign keys,
(tenant_id, id), so a row in one tenant can't reference a row in another. - Row-level security enabled and forced on every tenant table, with a policy that compares
tenant_idtocurrent_setting('app.tenant_id', true). - The app connecting as a role that doesn't own the tables and can't bypass RLS.
- The tenant set with
set_config(…, true)inside each transaction, never at session level on a pooled connection.
Multi-tenant SaaS in PostgreSQL: schema vs row-level compares the models in detail and walks through the RLS setup and the NestJS code that sets the tenant on every request.
A few other data model habits pay off at scale:
- Money as integers in the smallest unit (
total_cents bigint), never floating point. timestamptzeverywhere, stored in UTC. Time zones are a display concern.- Time-ordered IDs.
uuidv7()is built into PostgreSQL 18; it doesn't reveal row counts the way serial IDs do, and it inserts at the end of the index the way random UUIDs don't. - Constraints in the database:
NOT NULL,CHECK, foreign keys and unique indexes. Validation in DTOs protects the API; constraints protect the data from every other path, including the script someone runs in production next year. jsonbfor genuinely flexible data such as integration settings or custom fields, not as a way to avoid designing tables.
3. Connect NestJS to PostgreSQL#
Choosing a data layer#
| TypeORM | Prisma | Drizzle / Kysely | |
|---|---|---|---|
| Style | Entities with decorators, repositories | Schema file, generated client | SQL-shaped TypeScript |
| NestJS integration | Official @nestjs/typeorm |
Community patterns, simple to wire | Plain providers |
| Type safety of queries | Good for simple queries, weaker for complex ones | Very good | Very good |
| Raw SQL when you need it | Easy | Supported ($queryRaw) |
It's mostly SQL already |
| Migrations | Generated from entities | Generated from the schema | Drizzle Kit / hand-written |
All four can carry a large SaaS app. I use TypeORM on most NestJS projects because it's the documented default and the team usually knows it; Prisma is a great choice if you like its schema-first workflow. What matters more than the choice is that you can drop to SQL for the 5% of queries that need it, and that you read the SQL your ORM generates for the endpoints that matter.
Configure the pool deliberately#
// database/database.module.ts
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<Env, true>) => ({
type: 'postgres',
url: config.get('DATABASE_URL'),
autoLoadEntities: true,
synchronize: false, // schema changes go through migrations only
extra: {
max: config.get('DATABASE_POOL_MAX'), // connections per instance
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000, // fail fast instead of queueing forever
statement_timeout: 15_000, // no runaway query holds a connection
idle_in_transaction_session_timeout: 30_000,
application_name: 'api', // shows up in pg_stat_activity
},
}),
}),
The number to keep an eye on is total connections: API instances × pool size, plus workers × their pool size, plus migrations, cron jobs and your own psql session. Four API containers with 10 connections each and two workers with 5 each is 50 connections before anyone opens a console.
PostgreSQL handles each connection with its own process, so more connections isn't free. On Amazon RDS, max_connections is derived from instance memory and is on the order of a hundred on the smallest instances. When you approach the limit, or when you start autoscaling, put a pooler in front:
- PgBouncer in transaction mode multiplexes hundreds of client connections onto a few dozen server connections.
- RDS Proxy is the managed equivalent on AWS and also smooths over failovers.
Transaction pooling is why tenant context and any other session state must be set per transaction. Plan for it from the start and adding a pooler later is a config change instead of a rewrite.
4. Treat the schema like code#
synchronize: true is a development convenience that has dropped production columns more than once. In a SaaS app, every schema change is a migration file, reviewed in the pull request, tested in CI against a real PostgreSQL, and run once per release as its own step, before the new code starts.
The rule that makes zero-downtime deploys possible: every migration must work with the code that's already running. Add a column as nullable, deploy the code that writes it, backfill, then add the constraint. Rename by adding, copying and removing over several releases. Build indexes CONCURRENTLY. Running database migrations safely in CI/CD covers the expand-and-contract pattern, lock timeouts and the pipeline step.
5. Get transactions and side effects right#
Three patterns prevent most of the "how did the data end up like this?" incidents in SaaS backends:
Transactions around use cases, not repositories. "Create a subscription" might insert a subscription, update the customer and write an audit entry. Either all of that happens or none of it does. With nestjs-cls and its transactional plugin, a @Transactional() service method runs every repository call inside it in one transaction, without passing an EntityManager through every function.
Idempotency for anything that can be retried. Webhooks get delivered twice. Users double-click. Mobile clients retry on a flaky network. Store a key for each operation with a unique constraint, and a retry becomes a no-op instead of a second charge. Stripe webhooks that never double-charge shows the pattern with PostgreSQL.
An outbox for events. If you save an invoice and then publish "invoice created" to a queue, a crash between the two loses the event; publishing first and failing the save sends an event for an invoice that doesn't exist. Write the event to an outbox table in the same transaction as the invoice, and have a worker publish it. PostgreSQL's transaction guarantees do the hard part.
6. Keep queries fast as data grows#
At launch, every query is fast because every table is small. A year later, the same code runs against tens of millions of rows and the dashboard takes four seconds. The fixes are almost never exotic, and they start with measuring:
pg_stat_statementsshows which queries use the most total time. Fix those first, not the ones people complain about.EXPLAIN (ANALYZE, BUFFERS)shows why: a sequential scan, a missing composite index, estimates far from reality, a sort spilling to disk.- The usual suspects: indexes that don't match
WHEREplusORDER BY, ORM N+1 queries, deepOFFSETpagination, functions wrapped around indexed columns, andcount(*)on every page load.
On a legacy codebase, these fixes made our slow queries 30% faster without a rewrite or a bigger database. How I made legacy queries 30% faster with EXPLAIN ANALYZE goes through the method and each fix with before-and-after plans.
Design for it from the start, too: cursor pagination on every list endpoint, tenant_id leading every index, and a statement_timeout so one bad query can't hold connections for minutes.
7. Move slow work off the request path#
Anything that doesn't have to finish before the response goes to a queue: emails, PDF generation, webhooks you send out, imports, syncing with third-party APIs. The API stays fast and predictable, and failures get retried instead of shown to the user.
- BullMQ on Redis is my default with NestJS (
@nestjs/bullmq): retries with backoff, delayed jobs, rate limits and concurrency per queue. - A PostgreSQL-backed queue using
SELECT … FOR UPDATE SKIP LOCKEDis a reasonable choice when you'd rather not run Redis yet. It also lets you enqueue a job in the same transaction as the data it's about. - Run workers as a separate process from the same codebase (
NestFactory.createApplicationContext), so they scale on their own and a heavy job never competes with API requests for CPU. - Make every job idempotent and carry its tenant in the payload. Jobs will run twice sometimes, and a job without a tenant should fail, not run unscoped.
- Show a long job's progress in the open page by pushing it over Server-Sent Events. A progress bar only needs a one-way stream, and it keeps working through your existing proxy and auth.
8. Cache last, not first#
Redis in front of a slow query gives you a fast hit, the same slow miss, and a new problem: knowing when the cached data is wrong. Fix the query first. Then cache what's still expensive and read far more often than it changes: plan limits, feature flags, permission sets, a dashboard's aggregate numbers.
Use cache-aside with short TTLs, include the tenant in every cache key (t:{tenantId}:plan-limits), and delete keys when the underlying data changes. Never cache responses that contain user-specific data under a key that doesn't include the user.
9. Authentication, authorisation and tenant isolation#
- Short-lived access tokens (minutes) and rotating refresh tokens, or server-side sessions in Redis. Either works; what matters is that you can revoke access.
- Guards for authentication in
core/auth, applied globally, with a@Public()decorator for the few open routes. Opt-out is safer than opt-in. - Roles per tenant, not per user. The same person can be an admin in one workspace and a viewer in another, so memberships carry the role:
(user_id, tenant_id, role). - The tenant comes from the verified token or session, never from a header or body field the client can change. RLS then enforces it in the database.
- Rate limiting with
@nestjs/throttlerbacked by Redis, so limits hold across instances, keyed by tenant or API key for your public API. - Validation on every input: a global
ValidationPipewithwhitelistandforbidNonWhitelisted, so unexpected fields are rejected rather than silently written.
10. Make it observable#
You want to answer three questions in minutes: is it broken, since when, and why?
- Structured JSON logs with a request ID, tenant ID and user ID on every line, from the request context. Never log tokens, passwords or full request bodies.
- Metrics for request rate, error rate and p95 latency per route, plus database connections in use, queue depth and job failures.
- Database insight:
pg_stat_statements, slow-query plans fromauto_explain, and alerts on connection count, replication lag and storage. - Tracing with OpenTelemetry once you have workers and external calls, so one slow request can be followed from the API through the queue to the database.
CloudWatch logs, metrics and alarms for a Node.js API has a concrete setup for AWS.
11. Deploy and scale#
A stateless NestJS API scales by adding instances. "Stateless" means no sessions in memory, no files on local disk and no in-process schedulers that would run once per instance: sessions go to Redis or tokens, files to S3, and scheduled jobs to a queue with a single scheduler.
Package it as a small Docker image (NestJS Dockerfile: multi-stage builds), deploy it with zero downtime, and run it behind a load balancer across two Availability Zones. Deploying Node.js to production and AWS infrastructure for small SaaS teams cover that side end to end.
The growth path#
| Stage | Signs | What to add |
|---|---|---|
| Launch | First customers, one developer or a few | One API instance, one PostgreSQL, Redis when you need queues |
| Paying customers | An outage costs real money | Two API instances across two AZs, Multi-AZ database, workers separated, alerts |
| Growing data | Dashboards slow down, tables pass 10M rows | Query and index work, cursor pagination, caching for hot reads |
| Growing traffic | Connections near the limit, autoscaling | PgBouncer or RDS Proxy, read replica for reports and exports |
| Big tenants | One customer's load affects others | Move large tenants to their own database, partition the largest tables |
| Many teams | Teams block each other's releases | Split out the modules that need independent deploys |
Notice how late "microservices" appears, and how much of the table is PostgreSQL. A single well-tuned PostgreSQL primary on a modern instance handles far more than most SaaS products ever need; read replicas and moving large tenants to their own databases come long before anything as complex as sharding.
What you don't need yet#
- Microservices. Until teams need independent deploys, they cost more than they save.
- CQRS and event sourcing across the whole app. Useful for a specific, complex domain; overkill as a default.
- A separate search cluster. PostgreSQL full-text search and trigram indexes (
pg_trgm) cover most in-app search. - A NoSQL database "for scale". PostgreSQL's
jsonbhandles flexible data, and you keep transactions and joins. - Sharding. Indexes, replicas, partitioning and moving whale tenants come first, and take you a very long way.
The checklist#
- One NestJS app, with a module per business capability and enforced boundaries
- A tenancy model chosen deliberately; RLS forced on every tenant table
- The app's database role doesn't own the tables and can't bypass RLS
- Money as integers,
timestamptzin UTC, constraints in the database - Connection pool sized per instance, with total connections tracked and a statement timeout
- Migrations reviewed, tested in CI and backward-compatible;
synchronizeoff - Use cases run in transactions; retried operations are idempotent; events go through an outbox
-
pg_stat_statementson, list endpoints use cursor pagination - Slow work in queues, workers in their own process, jobs idempotent and tenant-scoped
- Global auth guard, roles per tenant, rate limits shared across instances
- Structured logs with request and tenant IDs; alerts on errors, latency and the database
- Stateless API instances behind a load balancer, deployed with zero downtime
Need a hand?#
Building and scaling SaaS backends on NestJS and PostgreSQL is the core of what I do, from the first data model to the queries, queues and infrastructure that keep it fast with real customers on it. See my full-stack engineering services, or email mhuzaifaa.dev@gmail.com with what you're building.
FAQ#
Is NestJS good for building a SaaS backend?#
Yes. NestJS gives a Node.js and TypeScript backend modules, dependency injection, guards, pipes and interceptors, which make a large codebase consistent across a team. It has official integrations for TypeORM, queues, configuration, caching and rate limiting, and it runs anywhere Node.js does. Its structure matters most once several developers work on the same API.
Should I use TypeORM or Prisma with NestJS and PostgreSQL?#
Both work well. TypeORM has an official NestJS package and an entity-and-repository style many teams know; Prisma has a schema-first workflow and a very type-safe generated client. Choose the one your team is comfortable with, make sure you can drop to raw SQL for complex queries, and check the SQL it generates for your busiest endpoints.
How do I scale a NestJS and PostgreSQL application?#
Keep the API stateless and scale it horizontally behind a load balancer, move slow work to queues processed by separate workers, and size connection pools so total connections stay under the database's limit, adding PgBouncer or RDS Proxy when they don't. On the database side, fix slow queries with pg_stat_statements and EXPLAIN ANALYZE before adding hardware, then add read replicas and partitioning as data grows.
How do I handle multi-tenancy in NestJS?#
Resolve the tenant from the verified token or session in middleware, store it in request context with AsyncLocalStorage (for example the nestjs-cls package), and pass it to PostgreSQL with set_config('app.tenant_id', id, true) at the start of each transaction. Row-level security policies on every tenant table then restrict every query to that tenant's rows.
Should a SaaS backend start with microservices?#
Usually not. A modular monolith, one deployable with clear internal module boundaries, is faster to build, cheaper to run and easier to debug. Split a module into its own service when there's a concrete reason, such as very different scaling needs or a team that must deploy independently. Clean module boundaries make that split straightforward later.
How many database connections does a NestJS app need?#
Fewer than you'd think. Total connections are the number of app instances times each pool's maximum, plus workers and other clients. A pool of 10 per instance is a sensible start; raise it only if requests wait for connections while the database still has headroom. When instances multiply, put PgBouncer or RDS Proxy in front instead of raising max_connections.
The series#
This guide is the hub of a series on building SaaS backends with NestJS and PostgreSQL:
- How I made legacy queries 30% faster with EXPLAIN ANALYZE
- NestJS project structure for large SaaS apps
- Multi-tenant SaaS in PostgreSQL: schema vs row-level
For running it in production, see Deploying Node.js to production: the complete guide and AWS infrastructure for small SaaS teams.
