A SaaS application on VPS infrastructure serves 500-5,000 tenants on a single server when designed with proper multi-tenant isolation, efficient job queues, and granular rate limiting. This guide covers PostgreSQL row-level security for tenant data isolation, middleware-based tenant resolution, Redis-backed job queues, per-tenant API rate limiting, and monitoring strategies that scale with your customer base.
These patterns apply to SaaS applications built with any backend framework (Django, Rails, Express, FastAPI). For production VPS tiers, visit the Pricing page.
Multi-Tenant Database Architecture
There are three approaches to multi-tenant databases, each with distinct tradeoffs:
| Approach | Data Isolation | Complexity | Cost per Tenant | Best For |
| Shared database, shared schema | Low | Low | $0.01-0.10 | Consumer SaaS, startups |
| Shared database, separate schemas | Medium | Medium | $0.50-2.00 | Mid-market SaaS |
| Separate databases per tenant | High | High | $5-50 | Enterprise, compliance |
For most SaaS startups, the shared database with shared schema approach using PostgreSQL Row-Level Security (RLS) provides the best balance of isolation, performance, and cost. This is the approach we detail below.
PostgreSQL Row-Level Security (RLS) for Tenant Isolation
RLS enforces tenant data isolation at the database level. Even if application code has a bug that omits a tenant filter, the database itself prevents cross-tenant data access.
-- Enable RLS on tenant-scoped tables
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
-- Create a function to get the current tenant ID from session
CREATE OR REPLACE FUNCTION current_tenant_id()
RETURNS INTEGER AS $$
SELECT current_setting('app.current_tenant_id', true)::INTEGER;
$$ LANGUAGE SQL STABLE;
-- RLS policy: tenants can only see their own data
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_tenant_id());
CREATE POLICY tenant_isolation ON tasks
USING (tenant_id = current_tenant_id());
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_tenant_id());
CREATE POLICY tenant_isolation ON team_members
USING (tenant_id = current_tenant_id());
-- Index for tenant_id on every tenant-scoped table
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_tasks_tenant ON tasks (tenant_id);
CREATE INDEX idx_invoices_tenant ON invoices (tenant_id);
CREATE INDEX idx_team_members_tenant ON team_members (tenant_id);
-- Composite indexes for common queries
CREATE INDEX idx_tasks_tenant_status ON tasks (tenant_id, status, created_at DESC);
CREATE INDEX idx_invoices_tenant_date ON invoices (tenant_id, invoice_date DESC);
-- Bypass RLS for admin/system operations
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
GRANT ALL ON projects TO app_user;
-- The superuser or a role with BYPASSRLS can access all data
Every query automatically filters by `tenant_id` without the application needing to add `WHERE tenant_id = ?` clauses. This eliminates an entire class of data leakage bugs.
Tenant Resolution Middleware
The middleware extracts the tenant identifier from the request and sets it in the database session. Three common strategies for tenant resolution:
<h1>FastAPI middleware example</h1>
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
class TenantMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Extract tenant from subdomain
host = request.headers.get("host", "")
subdomain = host.split(".")[0] if "." in host else None
if subdomain and subdomain not in ("www", "api", "app"):
tenant = await self.resolve_tenant(subdomain)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
request.state.tenant_id = tenant["id"]
request.state.tenant_slug = subdomain
else:
# Fallback to JWT claim
token_data = get_token_data(request)
if token_data and "tenant_id" in token_data:
request.state.tenant_id = token_data["tenant_id"]
else:
raise HTTPException(status_code=400, detail="Tenant context required")
# Set tenant context in database session
async with get_db_connection() as conn:
await conn.execute(
"SELECT set_config('app.current_tenant_id', $1, true)",
str(request.state.tenant_id)
)
response = await call_next(request)
return response
async def resolve_tenant(self, slug):
# Cache tenant lookups in Redis (5 minute TTL)
cached = await redis.get(f"tenant:{slug}")
if cached:
return json.loads(cached)
tenant = await db.fetch_one(
"SELECT id, slug, plan, status FROM tenants WHERE slug = $1 AND status = 'active'",
slug
)
if tenant:
await redis.setex(f"tenant:{slug}", 300, json.dumps(dict(tenant)))
return tenant
Caching tenant lookups in Redis avoids a database query on every request. With 1,000 tenants making 100 requests/minute each, that eliminates 100,000 database queries per minute.
For platform-level multi-tenant architecture, see how Arxentra handles tenant isolation on the Services Overview page.
Background Job Processing
SaaS applications need background jobs for email notifications, report generation, data imports, webhook deliveries, and scheduled tasks. Use Redis-backed queues with per-tenant isolation:
<h1>Job queue with tenant-aware processing</h1>
import asyncio
import json
import redis.asyncio as redis
class TenantJobQueue:
def __init__(self, redis_url="redis://localhost:6379/1"):
self.redis = redis.from_url(redis_url)
async def enqueue(self, tenant_id, job_type, payload, priority="default"):
"""Add a job to the tenant-specific queue."""
job = {
"id": str(uuid.uuid4()),
"tenant_id": tenant_id,
"type": job_type,
"payload": payload,
"created_at": datetime.utcnow().isoformat(),
"attempts": 0,
}
queue_key = f"queue:{priority}:{tenant_id}"
await self.redis.lpush(queue_key, json.dumps(job))
return job["id"]
async def process(self, priority="default", batch_size=10):
"""Process jobs across all tenants fairly (round-robin)."""
# Get all tenant queues for this priority
keys = await self.redis.keys(f"queue:{priority}:*")
for key in keys:
# Process up to batch_size jobs per tenant per cycle
for _ in range(batch_size):
raw = await self.redis.rpop(key)
if not raw:
break
job = json.loads(raw)
try:
await self.execute_job(job)
except Exception as e:
await self.handle_failure(job, e)
async def execute_job(self, job):
"""Execute a job with tenant context."""
# Set tenant context before processing
async with get_db_connection() as conn:
await conn.execute(
"SELECT set_config('app.current_tenant_id', $1, true)",
str(job["tenant_id"])
)
handlers = {
"send_email": self.handle_email,
"generate_report": self.handle_report,
"webhook_delivery": self.handle_webhook,
"data_import": self.handle_import,
}
handler = handlers.get(job["type"])
if handler:
await handler(job["payload"], conn)
async def handle_failure(self, job, error):
"""Retry failed jobs with exponential backoff."""
job["attempts"] += 1
if job["attempts"] < 5:
delay = 2 ** job["attempts"] * 60 # 2, 4, 8, 16, 32 minutes
job["retry_after"] = (datetime.utcnow() + timedelta(seconds=delay)).isoformat()
await self.redis.lpush(f"queue:retry:{job['tenant_id']}", json.dumps(job))
else:
await self.redis.lpush("queue:dead", json.dumps(job))
Round-robin processing across tenant queues prevents a single tenant with a large job backlog from starving other tenants. The retry mechanism uses exponential backoff (2, 4, 8, 16, 32 minutes) to handle transient failures without overwhelming downstream services.
Per-Tenant API Rate Limiting
Rate limiting for SaaS must operate at multiple levels: global rate limit, per-tenant rate limit, and per-user rate limit. Each tier has a different purpose:
| Level | Purpose | Typical Limit | Enforcement |
| Global | Protect infrastructure | 10,000 req/min | Nginx |
| Per-tenant | Fair usage across tenants | Plan-based | Application middleware |
| Per-user | Prevent abuse within tenant | 100-500 req/min | Application middleware |
| Per-endpoint | Protect expensive operations | 10-50 req/min | Route-level |
<h1>Sliding window rate limiter with Redis</h1>
import time
import redis.asyncio as redis
class TenantRateLimiter:
PLAN_LIMITS = {
"free": {"requests_per_minute": 60, "requests_per_day": 1000},
"starter": {"requests_per_minute": 300, "requests_per_day": 10000},
"professional": {"requests_per_minute": 1000, "requests_per_day": 100000},
"enterprise": {"requests_per_minute": 5000, "requests_per_day": 1000000},
}
def __init__(self, redis_client):
self.redis = redis_client
async def check_rate_limit(self, tenant_id, plan, endpoint=None):
"""Check if request is within rate limits. Returns (allowed, remaining, reset_at)."""
limits = self.PLAN_LIMITS.get(plan, self.PLAN_LIMITS["free"])
now = time.time()
minute_key = f"rate:{tenant_id}:min:{int(now // 60)}"
day_key = f"rate:{tenant_id}:day:{int(now // 86400)}"
pipe = self.redis.pipeline()
pipe.incr(minute_key)
pipe.expire(minute_key, 120)
pipe.incr(day_key)
pipe.expire(day_key, 172800)
results = await pipe.execute()
minute_count = results[0]
day_count = results[2]
if minute_count > limits["requests_per_minute"]:
return False, 0, int((int(now // 60) + 1) * 60)
if day_count > limits["requests_per_day"]:
return False, 0, int((int(now // 86400) + 1) * 86400)
remaining = limits["requests_per_minute"] - minute_count
return True, remaining, int((int(now // 60) + 1) * 60)
Include rate limit headers in every API response so clients can implement backoff:
- `X-RateLimit-Limit`: Maximum requests per window
- `X-RateLimit-Remaining`: Requests remaining in current window
- `X-RateLimit-Reset`: Unix timestamp when the window resets
- `Retry-After`: Seconds to wait before retrying (on 429 responses)
Monitoring and Observability
SaaS monitoring requires per-tenant visibility to debug issues and enforce fair usage:
<h1>Per-tenant metrics collection</h1>
from prometheus_client import Counter, Histogram, Gauge
<h1>Request metrics</h1>
request_count = Counter(
"saas_requests_total",
"Total API requests",
["tenant_id", "endpoint", "method", "status"]
)
request_duration = Histogram(
"saas_request_duration_seconds",
"Request duration in seconds",
["tenant_id", "endpoint"],
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]
)
active_tenants = Gauge(
"saas_active_tenants",
"Number of active tenants in last 5 minutes"
)
job_queue_depth = Gauge(
"saas_job_queue_depth",
"Number of pending jobs per tenant",
["tenant_id", "priority"]
)
Key metrics to track per tenant:
Review the Cloud Computing platform for built-in monitoring dashboards. For more details on how Arxentra handles multi-tenant observability, see the Documentation.
Database Connection Pooling
With hundreds of tenants, connection pooling is essential. Without it, each request opens a new database connection (20-50ms overhead), and you quickly hit PostgreSQL's default 100-connection limit.
<h1>Install PgBouncer for connection pooling</h1>
sudo apt install pgbouncer
<h1>/etc/pgbouncer/pgbouncer.ini</h1>
[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
server_lifetime = 3600
server_idle_timeout = 600
log_connections = 0
log_disconnections = 0
stats_period = 60
With PgBouncer in `transaction` mode, 1,000 concurrent application connections share 25 PostgreSQL connections. Each connection is assigned to an app request only for the duration of a transaction, then returned to the pool. This reduces PostgreSQL resource usage by 95% while supporting 40x more concurrent requests.
Contact the team through the Contact page for SaaS architecture consulting.
Frequently Asked Questions
Q: How many tenants can a single VPS handle?
A: A 4 vCPU, 8GB RAM VPS handles 500-2,000 tenants depending on activity patterns. The limiting factor is usually concurrent active tenants (those making requests right now), not total tenant count. If 5% of tenants are active simultaneously, 2,000 total tenants means 100 concurrent users, which a tuned VPS handles easily.
Q: When should I move from shared schema to separate schemas?
A: When you have enterprise customers requiring data isolation guarantees, compliance requirements (SOC 2, HIPAA) that mandate logical separation, or tenants with vastly different data volumes (one tenant with 10 million rows slowing queries for all tenants). Separate schemas add 30-50% operational complexity.
Q: How do I handle tenant-specific customizations?
A: Store configuration in a `tenant_settings` table with JSON columns for flexible schema. Never branch application code per tenant. Use feature flags to enable/disable functionality per plan or tenant. This keeps a single codebase deployable while supporting customization.
Q: What is the best way to handle tenant data backups?
A: For shared schema, back up the entire database and use `pg_dump` with table filtering for tenant-specific data exports. Schedule automated backups every 6 hours with 30-day retention. Store backups in object storage (not on the same VPS). Test restore procedures monthly.
Q: How do I prevent one tenant from affecting others (noisy neighbor)?
A: Layer three defenses: (1) per-tenant rate limiting at the API level, (2) PostgreSQL `statement_timeout` set to 30 seconds to kill runaway queries, (3) per-tenant resource quotas (storage, job queue depth, API calls per day). Monitor these metrics and alert when any tenant exceeds 80% of their quota.
Q: Should I use a separate Redis instance per tenant?
A: No. Use key prefixes (`tenant:{id}:cache:*`) and Redis databases (0-15) for logical separation. A single Redis instance with 1GB of memory handles 10,000+ tenants. Separate Redis instances only make sense when you need to guarantee cache memory per tenant or comply with data residency requirements.