Tutorial Building a SaaS — Part 1

Building a SaaS, part 1: architecture that won't box you in

Surendra Tamang

Surendra Tamang

25 min read intermediate

Prerequisites

  • Basic understanding of web development
  • Familiarity with JavaScript/TypeScript
  • Knowledge of databases (SQL)

Architecture fundamentals

This series builds a multi-tenant SaaS from scratch. Part 1 covers the decisions I make before writing any feature code: the stack, the project skeleton, and the initial database schema.

Series Overview

The six parts:

  1. Part 1: Architecture Fundamentals (This tutorial)
  2. Part 2: Multi-Tenant Architecture
  3. Part 3: Database Design and Caching
  4. Part 4: Authentication and Security
  5. Part 5: Performance and Optimization
  6. Part 6: Billing and Deployment

What You’ll Learn in This Part

  • Core principles of SaaS architecture
  • Choosing the right technology stack
  • Setting up your development environment
  • Creating the initial project structure
  • Understanding scalability patterns

Understanding SaaS Architecture Fundamentals

Core Principles of SaaS Design

A handful of principles drive most of the decisions in this series:

1. Multi-Tenancy

A single application instance serves multiple customers (tenants) while keeping their data isolated and secure.

2. Scalability

Your architecture must handle growth without requiring fundamental changes. This means:

  • Horizontal scaling: Adding more servers to handle load
  • Vertical scaling: Upgrading server resources
  • Auto-scaling: Automatically adjusting resources based on demand

3. High Availability

Achieving 99.9%+ uptime through:

  • Redundancy: Multiple instances of critical components
  • Load balancing: Distributing traffic across servers
  • Failover mechanisms: Automatic switching to backup systems

4. Performance

Sub-second response times regardless of load through:

  • Caching strategies: Multi-layer caching
  • Database optimization: Indexes, query optimization
  • CDN usage: Serving static assets from edge locations

5. Security

Data isolation and protection at every layer:

  • Authentication: Verifying user identity
  • Authorization: Controlling access to resources
  • Encryption: Protecting data at rest and in transit
  • Audit logging: Tracking all system activities

6. Maintainability

Easy updates without customer disruption:

  • Microservices architecture: Independent service deployment
  • Blue-green deployments: Zero-downtime updates
  • Feature flags: Gradual feature rollout

Choosing Your Technology Stack

This is the stack I use. Swap pieces if you have reasons; the structure matters more than the brand names.

Frontend Stack

tech-stack/frontend.config.ts
export const frontendStack = {
framework: 'Next.js 14',
ui: {
styling: 'Tailwind CSS',
components: 'Radix UI',
icons: 'Lucide React'
},
state: {
global: 'Zustand',
server: 'TanStack Query (React Query)',
forms: 'React Hook Form + Zod'
},
testing: {
unit: 'Vitest',
integration: 'Testing Library',
e2e: 'Playwright'
},
tooling: {
bundler: 'Turbopack',
linting: 'ESLint',
formatting: 'Prettier'
}
}

Backend Stack

tech-stack/backend.config.ts
export const backendStack = {
runtime: 'Node.js 20 LTS + TypeScript',
framework: {
primary: 'NestJS', // Enterprise-grade framework
alternative: 'Express + TypeScript' // Lightweight option
},
api: {
rest: 'RESTful API with OpenAPI',
graphql: 'Apollo Server (optional)',
realtime: 'Socket.io or WebSockets'
},
database: {
primary: 'PostgreSQL 15',
cache: 'Redis 7',
search: 'Elasticsearch 8',
timeseries: 'TimescaleDB (for analytics)'
},
queue: {
primary: 'BullMQ',
alternative: 'AWS SQS'
},
storage: {
files: 'AWS S3 or MinIO',
cdn: 'CloudFront or Cloudflare'
}
}

Infrastructure Stack

tech-stack/infrastructure.config.ts
export const infrastructureStack = {
hosting: {
cloud: 'AWS / Google Cloud / Azure',
alternative: 'DigitalOcean / Linode'
},
containers: {
runtime: 'Docker',
orchestration: 'Kubernetes (K8s)',
registry: 'Docker Hub / AWS ECR'
},
monitoring: {
apm: 'Datadog / New Relic',
logs: 'ELK Stack (Elasticsearch, Logstash, Kibana)',
metrics: 'Prometheus + Grafana',
errors: 'Sentry'
},
ci_cd: {
pipeline: 'GitHub Actions / GitLab CI',
deployment: 'ArgoCD / Flux',
secrets: 'HashiCorp Vault / AWS Secrets Manager'
}
}

Setting Up the Development Environment

Step 1: Initialize the Monorepo

We’ll use a monorepo structure to manage all our services:

Terminal window
# Create project directory
mkdir saas-platform && cd saas-platform
# Initialize pnpm workspace
pnpm init
# Create workspace configuration
cat > pnpm-workspace.yaml << EOF
packages:
- 'apps/*'
- 'packages/*'
- 'services/*'
EOF
# Create directory structure
mkdir -p apps/web apps/admin packages/shared packages/database services/api services/worker

Step 2: Set Up the API Service

Terminal window
# Navigate to API service
cd services/api
# Initialize NestJS project
npx @nestjs/cli new . --package-manager pnpm --skip-install
# Install dependencies
pnpm add @nestjs/config @nestjs/jwt @nestjs/passport
pnpm add @prisma/client prisma
pnpm add bcrypt class-validator class-transformer
pnpm add -D @types/bcrypt

Step 3: Configure TypeScript

Create a base TypeScript configuration:

tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@shared/*": ["../../packages/shared/src/*"],
"@database/*": ["../../packages/database/src/*"]
},
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
}

Step 4: Database Setup with Prisma

Terminal window
# Initialize Prisma
cd packages/database
pnpm init
pnpm add @prisma/client
pnpm add -D prisma
# Initialize Prisma schema
npx prisma init

Create the initial schema:

packages/database/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Core tenant model
model Tenant {
id String @id @default(uuid())
slug String @unique @db.VarChar(63)
name String @db.VarChar(255)
status TenantStatus @default(ACTIVE)
subscriptionTier SubscriptionTier @default(FREE)
subscriptionExpiresAt DateTime?
settings Json @default("{}")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
projects Project[]
auditLogs AuditLog[]
@@index([slug])
@@index([status])
@@index([subscriptionTier, subscriptionExpiresAt])
}
model User {
id String @id @default(uuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
email String @db.VarChar(255)
passwordHash String
name String? @db.VarChar(255)
role UserRole @default(MEMBER)
status UserStatus @default(ACTIVE)
lastLoginAt DateTime?
twoFactorEnabled Boolean @default(false)
twoFactorSecret String?
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projects ProjectUser[]
auditLogs AuditLog[]
sessions Session[]
@@unique([tenantId, email])
@@index([tenantId, email])
@@index([tenantId, role])
}
model Project {
id String @id @default(uuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String @db.VarChar(255)
description String?
status ProjectStatus @default(ACTIVE)
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users ProjectUser[]
@@index([tenantId])
@@index([tenantId, status])
}
model ProjectUser {
id String @id @default(uuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ProjectRole @default(VIEWER)
joinedAt DateTime @default(now())
@@unique([projectId, userId])
@@index([projectId])
@@index([userId])
}
model Session {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
ipAddress String?
userAgent String?
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([token])
}
model AuditLog {
id BigInt @id @default(autoincrement())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
action String @db.VarChar(100)
entityType String? @db.VarChar(50)
entityId String? @db.VarChar(255)
oldValues Json?
newValues Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([tenantId])
@@index([tenantId, userId])
@@index([tenantId, action])
@@index([createdAt])
}
// Enums
enum TenantStatus {
ACTIVE
SUSPENDED
CANCELLED
}
enum SubscriptionTier {
FREE
STARTER
PRO
ENTERPRISE
}
enum UserStatus {
ACTIVE
INACTIVE
SUSPENDED
}
enum UserRole {
OWNER
ADMIN
MEMBER
VIEWER
}
enum ProjectStatus {
ACTIVE
ARCHIVED
DELETED
}
enum ProjectRole {
OWNER
EDITOR
VIEWER
}

Creating the Initial Project Structure

API Service Structure

services/api/src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
import { setupSwagger } from './setup-swagger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Get config service
const configService = app.get(ConfigService);
// Global prefix
app.setGlobalPrefix('api/v1');
// Enable CORS
app.enableCors({
origin: configService.get('CORS_ORIGINS')?.split(',') || '*',
credentials: true,
});
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// Setup Swagger documentation
if (configService.get('NODE_ENV') !== 'production') {
setupSwagger(app);
}
const port = configService.get('PORT') || 3000;
await app.listen(port);
console.log(`🚀 Application is running on: http://localhost:${port}/api/v1`);
console.log(`📚 Swagger documentation: http://localhost:${port}/api-docs`);
}
bootstrap();

Environment Configuration

services/api/src/config/configuration.ts
export default () => ({
node_env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3000', 10),
database: {
url: process.env.DATABASE_URL,
pool_size: parseInt(process.env.DATABASE_POOL_SIZE || '10', 10),
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
},
jwt: {
secret: process.env.JWT_SECRET,
access_expiry: process.env.JWT_ACCESS_EXPIRY || '15m',
refresh_expiry: process.env.JWT_REFRESH_EXPIRY || '30d',
},
cors: {
origins: process.env.CORS_ORIGINS || 'http://localhost:3001',
},
rate_limit: {
window_ms: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
max_requests: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
},
});

Understanding Scalability Patterns

1. Horizontal Scaling Pattern

infrastructure/kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: saas-api
spec:
replicas: 3 # Start with 3 instances
selector:
matchLabels:
app: saas-api
template:
metadata:
labels:
app: saas-api
spec:
containers:
- name: api
image: saas-api:latest
ports:
- containerPort: 3000
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
env:
- name: NODE_ENV
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url

2. Auto-scaling Configuration

infrastructure/kubernetes/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: saas-api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: saas-api
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100 # Double the pods
periodSeconds: 60
- type: Pods
value: 4 # Or add 4 pods
periodSeconds: 60
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50 # Remove 50% of pods
periodSeconds: 300

3. Load Balancing

infrastructure/nginx/nginx.conf
upstream api_backend {
least_conn; # Use least connection method
server api1.internal:3000 weight=5;
server api2.internal:3000 weight=5;
server api3.internal:3000 weight=5;
# Health check
keepalive 32;
}
server {
listen 80;
server_name api.saas-platform.com;
location / {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

Development Workflow

1. Local Development Setup

Terminal window
# Clone the repository
git clone https://github.com/your-org/saas-platform.git
cd saas-platform
# Install dependencies
pnpm install
# Setup environment variables
cp .env.example .env.local
# Start PostgreSQL and Redis with Docker
docker-compose up -d postgres redis
# Run database migrations
pnpm --filter @saas/database migrate:dev
# Start development servers
pnpm dev

2. Docker Compose for Local Development

docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: saas_postgres
environment:
POSTGRES_USER: saas_user
POSTGRES_PASSWORD: saas_password
POSTGRES_DB: saas_dev
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U saas_user"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: saas_redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
mailhog:
image: mailhog/mailhog
container_name: saas_mailhog
ports:
- "1025:1025" # SMTP server
- "8025:8025" # Web UI
environment:
MH_STORAGE: memory
MH_SMTP_BIND_ADDR: 0.0.0.0:1025
MH_API_BIND_ADDR: 0.0.0.0:8025
MH_UI_BIND_ADDR: 0.0.0.0:8025
volumes:
postgres_data:
redis_data:

Conventions

1. Code Organization

  • Modular architecture: Separate concerns into modules
  • Dependency injection: Use NestJS’s built-in DI container
  • Repository pattern: Abstract database operations
  • Service layer: Business logic separate from controllers

2. Error Handling

common/exceptions/business-exception.ts
export class BusinessException extends Error {
constructor(
public readonly code: string,
public readonly message: string,
public readonly statusCode: number = 400,
public readonly details?: any
) {
super(message);
this.name = 'BusinessException';
}
}
// Usage
throw new BusinessException(
'TENANT_LIMIT_EXCEEDED',
'Your plan does not support more users',
403,
{ currentLimit: 5, requested: 6 }
);

3. Logging Strategy

common/logging/logger.service.ts
import { Injectable, LoggerService } from '@nestjs/common';
import * as winston from 'winston';
@Injectable()
export class CustomLoggerService implements LoggerService {
private logger: winston.Logger;
constructor() {
this.logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
}),
new winston.transports.File({
filename: 'logs/combined.log',
}),
],
});
}
log(message: string, context?: string) {
this.logger.info(message, { context });
}
error(message: string, trace?: string, context?: string) {
this.logger.error(message, { trace, context });
}
warn(message: string, context?: string) {
this.logger.warn(message, { context });
}
debug(message: string, context?: string) {
this.logger.debug(message, { context });
}
}

Summary and Next Steps

That covers the stack, the monorepo skeleton, the initial Prisma schema, and the scaling patterns the rest of the series builds on.

What’s Next?

Part 2: Multi-Tenant Architecture covers:

  • Implementing different multi-tenancy strategies
  • Tenant isolation and data security
  • Dynamic tenant provisioning
  • Tenant-aware middleware and context management

Resources

Practice Exercise

Before moving to Part 2, try implementing:

  1. Create a simple health check endpoint
  2. Add request logging middleware
  3. Implement a basic rate limiter
  4. Set up Swagger documentation