Building a Full SaaS Application with NestJS, React, PostgreSQL and Docker
A step-by-step, production-grade guide: architecture, multi-tenant database design, JWT auth, NestJS APIs, a React frontend, Docker, CI/CD with GitHub Actions, scalability, and the best practices that hold up in production.

Most “build a SaaS” tutorials stop at a todo list with a login form. This one doesn’t. We’re going to design the architecture of a real multi-tenant SaaS, build a typed API with NestJS, model data properly in PostgreSQL, wire up a React frontend with real authentication, containerize everything with Docker, ship it through a CI/CD pipeline, and talk honestly about what it takes to scale.
Think of this as the blueprint — the decisions and the code that separate a demo from something you’d actually charge money for.
The architecture at a glance
A SaaS has a few non-negotiable concerns: tenancy (who owns what), authentication, a clean API boundary, and a deployable shape. Here’s the system we’re building:
┌─────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ React SPA │ HTTPS │ NestJS API │ TCP │ PostgreSQL │
│ (Vite + TS) │ ─────▶ │ Auth · Tenancy · REST│ ─────▶ │ (per-tenant │
│ │ ◀───── │ Guards · Validation │ ◀───── │ row scope) │
└─────────────┘ JSON └──────────────────────┘ └──────────────┘
│ │ │
└──────────────── all containerized with Docker ──────────┘
Nginx (frontend) · Node (API) · Postgres
We’ll use a modular monolith: one deployable NestJS app, cleanly split into feature modules. It’s the right call for an early-stage SaaS — the simplicity of a monolith with boundaries you can later peel into services if you ever need to.
| Layer | Choice | Why |
|---|---|---|
| Frontend | React + Vite + TypeScript | Fast DX, typed end-to-end |
| API | NestJS | Opinionated, DI, testable, batteries included |
| Database | PostgreSQL | Relational integrity, JSONB, row-level security |
| ORM | Prisma or TypeORM | Typed queries, migrations |
| Auth | JWT (access + refresh) | Stateless, scales horizontally |
| Packaging | Docker + Compose | Reproducible, env-parity |
| CI/CD | GitHub Actions | Native to most repos |
Project structure
A monorepo keeps the API and web app in one place with shared types:
saas/
├── apps/
│ ├── api/ # NestJS
│ │ └── src/
│ │ ├── auth/ # login, JWT, guards
│ │ ├── tenants/ # organizations / workspaces
│ │ ├── users/
│ │ ├── billing/ # plans, subscriptions
│ │ └── projects/ # your actual product domain
│ └── web/ # React + Vite
├── packages/
│ └── shared/ # DTO types shared by api + web
├── docker-compose.yml
└── .github/workflows/ci.yml
Designing the database for multi-tenancy
The single most important SaaS decision is how you isolate tenants. Three common strategies:
| Strategy | Isolation | Cost | Good for |
|---|---|---|---|
Shared schema, tenant_id column | Logical | Lowest | Most SaaS (start here) |
| Schema-per-tenant | Stronger | Medium | Compliance-sensitive |
| Database-per-tenant | Strongest | Highest | Enterprise / regulated |
For 95% of SaaS, shared schema with a tenant_id foreign key on every
tenant-owned table is the right starting point. Here’s the core schema:
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email CITEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, email)
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Every tenant-scoped query MUST filter by tenant_id. Index it.
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
Note: For defense-in-depth, PostgreSQL Row-Level Security (RLS) can enforce
tenant_idat the database layer, so a forgottenWHEREclause can’t leak data across tenants. Setapp.tenant_idper connection and add an RLS policy. It’s the safety net that turns a bug into a non-event.
Authentication: JWT done right
Stateless JWT auth scales horizontally (no session store to share). The pattern: a short-lived access token and a long-lived refresh token.
// apps/api/src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as argon2 from 'argon2';
@Injectable()
export class AuthService {
constructor(
private readonly users: UsersService,
private readonly jwt: JwtService,
) {}
async validate(email: string, password: string) {
const user = await this.users.findByEmail(email);
if (!user || !(await argon2.verify(user.passwordHash, password))) {
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
async issueTokens(user: { id: string; tenantId: string; role: string }) {
const payload = { sub: user.id, tid: user.tenantId, role: user.role };
return {
accessToken: await this.jwt.signAsync(payload, { expiresIn: '15m' }),
refreshToken: await this.jwt.signAsync(payload, { expiresIn: '7d' }),
};
}
}
Warning: Never store passwords in anything but a slow, salted hash — use argon2 or bcrypt, never SHA-256. And put the
tenantId(tid) in the token so every request carries its tenant scope.
A guard extracts the tenant from the verified token and attaches it to the request
— so your services never trust a tenant_id sent by the client:
// apps/api/src/auth/jwt.strategy.ts
validate(payload: { sub: string; tid: string; role: string }) {
return { userId: payload.sub, tenantId: payload.tid, role: payload.role };
}
Building the API with NestJS
NestJS gives you modules, dependency injection, and validation for free. A tenant-scoped resource looks like this:
// apps/api/src/projects/projects.controller.ts
@UseGuards(JwtAuthGuard)
@Controller('projects')
export class ProjectsController {
constructor(private readonly projects: ProjectsService) {}
@Get()
findAll(@CurrentUser() user: AuthUser) {
return this.projects.findAllForTenant(user.tenantId);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateProjectDto) {
return this.projects.create(user.tenantId, dto);
}
}
DTOs with class-validator validate input at the boundary — wire up a global
ValidationPipe and bad requests never reach your service:
// apps/api/src/projects/dto/create-project.dto.ts
export class CreateProjectDto {
@IsString() @MinLength(2) @MaxLength(80)
name!: string;
}
The service is where tenant scoping is enforced — always filter by the
tenantId from the token:
// apps/api/src/projects/projects.service.ts
findAllForTenant(tenantId: string) {
return this.prisma.project.findMany({ where: { tenantId } });
}
Wiring up the React frontend
The frontend talks to the API over JSON, stores the access token in memory (not
localStorage, to limit XSS blast radius), and uses an interceptor to refresh
silently on 401.
// apps/web/src/lib/api.ts
let accessToken: string | null = null;
export async function apiFetch(path: string, init: RequestInit = {}) {
const res = await fetch(`/api${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...init.headers,
},
credentials: 'include', // refresh token rides in an httpOnly cookie
});
if (res.status === 401) {
accessToken = await refreshAccessToken();
return apiFetch(path, init); // retry once
}
return res.json();
}
A typed data-fetching hook (TanStack Query) keeps components clean:
// apps/web/src/features/projects/useProjects.ts
export function useProjects() {
return useQuery({
queryKey: ['projects'],
queryFn: () => apiFetch('/projects') as Promise<Project[]>,
});
}
Tip: Put the access token in memory and the refresh token in an httpOnly, Secure, SameSite=Strict cookie. Memory-only access tokens vanish on reload (the refresh flow restores them) and aren’t readable by injected JS.
Containerizing with Docker
Each app gets a small, multi-stage image. The API:
# apps/api/Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
The whole stack runs locally with one command via Compose:
# docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: saas
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 5
api:
build: ./apps/api
environment:
DATABASE_URL: postgres://postgres:${DB_PASSWORD}@db:5432/saas
JWT_SECRET: ${JWT_SECRET}
depends_on:
db: { condition: service_healthy }
ports: ["3000:3000"]
web:
build: ./apps/web
ports: ["8080:80"]
depends_on: [api]
volumes:
pgdata:
docker compose up --build # entire stack: db + api + web
CI/CD with GitHub Actions
Every push runs the gate: install, lint, test, then build and push images on
main.
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env: { POSTGRES_PASSWORD: test }
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready" --health-interval 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm test
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/postgres
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Build & push images, then trigger deploy"
Note: Run database migrations as a deploy step, not at app boot. Booting three API replicas that each try to migrate is a race condition waiting to happen. Migrate once, then roll out.
Scaling considerations
You won’t need all of this on day one — but design so you don’t have to rewrite:
- Stateless API. JWT auth + no in-memory session means you can run N replicas behind a load balancer and scale horizontally.
- Connection pooling. Postgres connections are precious. Put PgBouncer in front so 50 API replicas don’t open 5,000 connections.
- Caching. Add Redis for hot reads, rate limiting, and background-job queues (BullMQ) before you reach for anything exotic.
- Read replicas. Route heavy reporting queries to a replica to protect the primary.
- Async work. Move email, webhooks, and exports to a queue so requests stay fast.
- Observability. Structured logs, metrics (Prometheus), and tracing (OpenTelemetry) from the start — you can’t scale what you can’t see.
Production best practices checklist
- ✅ Validate everything at the boundary (
ValidationPipe+ DTOs). - ✅ Enforce tenancy from the token, never from client input; add RLS as a net.
- ✅ Hash passwords with argon2/bcrypt; rotate JWT secrets.
- ✅ Rate-limit auth endpoints (
@nestjs/throttler). - ✅ Health checks (
/health) for the load balancer and Compose. - ✅ Secrets in env/secret manager — never in the image or repo.
- ✅ Migrations versioned and run on deploy, not boot.
- ✅ Backups automated and restore-tested — an untested backup isn’t a backup.
- ✅ HTTPS everywhere, security headers, CORS locked to your domains.
Wrapping up
That’s the full shape of a production SaaS: a typed NestJS API with strict tenant isolation, a clean React frontend with a secure auth flow, PostgreSQL modeled for multi-tenancy, Docker for reproducible environments, and a CI/CD pipeline that gates every change. Start as a modular monolith, lean on managed Postgres, add Redis and replicas when traffic demands it — and resist the urge to build for a scale you don’t have yet.
Build the boring, correct version first. The boring version is the one that’s still running — and still making money — a year from now.
Want to go deeper on any layer? Our NestJS, React, and Docker documentation cover the fundamentals in depth — and if you’d like a hand building your SaaS, get in touch.
Related articles
A Mental Model for React Rendering That Actually Sticks
Stop guessing why your component re-rendered. A clear, durable model of how React decides what to render and when — and what that means for performance.
Dockerizing a Java App the Right Way in 2026
Multi-stage builds, layered JARs, distroless images, and the JVM flags that make containers behave. A production Dockerfile you can actually copy.

MCP Servers Explained: The Future of AI Tool Integration
What the Model Context Protocol (MCP) is, how MCP servers work, why it beats bespoke API glue, a hands-on server example, the growing ecosystem, security considerations, and where it's all heading.
Have a project or an idea?
We don't just write about software — we build it. Tell us what you're working on and we'll get back within 1–2 business days.