Building SaaS on VPS: Multi-Tenant Database Design, Job Queues, and API Rate Limiting

Published 22 min read
saasvps hostingmulti-tenantrate limitingbackground jobsmonitoring

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:

ApproachData IsolationComplexityCost per TenantBest For

Shared database, shared schemaLowLow$0.01-0.10Consumer SaaS, startups
Shared database, separate schemasMediumMedium$0.50-2.00Mid-market SaaS
Separate databases per tenantHighHigh$5-50Enterprise, 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:

  • Subdomain: `acme.app.com` resolves tenant "acme"
  • Path prefix: `app.com/org/acme` resolves tenant "acme"
  • Header/token: JWT contains `tenant_id` claim
  • <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:

    LevelPurposeTypical LimitEnforcement

    GlobalProtect infrastructure10,000 req/minNginx
    Per-tenantFair usage across tenantsPlan-basedApplication middleware
    Per-userPrevent abuse within tenant100-500 req/minApplication middleware
    Per-endpointProtect expensive operations10-50 req/minRoute-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:

  • API request rate: Identifies tenants approaching rate limits
  • P95 response time: Detects tenants with inefficient usage patterns
  • Error rate: Flags integration issues for specific tenants
  • Storage usage: Prevents a single tenant from consuming all disk space
  • Job queue depth: Identifies tenants with growing backlogs
  • 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.