Skip to content
Full-Stack Jun 11, 2026 16 min read

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.

D

DevCraftly Team

DevCraftly

Share
Building a Full SaaS Application with NestJS, React, PostgreSQL and Docker
Building a Full SaaS Application with NestJS, React, PostgreSQL and Docker

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.

LayerChoiceWhy
FrontendReact + Vite + TypeScriptFast DX, typed end-to-end
APINestJSOpinionated, DI, testable, batteries included
DatabasePostgreSQLRelational integrity, JSONB, row-level security
ORMPrisma or TypeORMTyped queries, migrations
AuthJWT (access + refresh)Stateless, scales horizontally
PackagingDocker + ComposeReproducible, env-parity
CI/CDGitHub ActionsNative 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:

StrategyIsolationCostGood for
Shared schema, tenant_id columnLogicalLowestMost SaaS (start here)
Schema-per-tenantStrongerMediumCompliance-sensitive
Database-per-tenantStrongestHighestEnterprise / 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_id at the database layer, so a forgotten WHERE clause can’t leak data across tenants. Set app.tenant_id per 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.

#nestjs #react #postgresql #docker #saas #ci-cd