CloudPloy

Hosting SaaS Applications on CloudPloy

SaaS products have different hosting requirements from informational sites or internal tools. Your application needs to serve dozens or hundreds of independent customers, each with their own data - and it needs to do this reliably while you continuously deploy new features. CloudPloy's managed container hosting handles the deployment infrastructure so your team focuses on product development, not server administration.

This page covers architecture patterns for PHP and Laravel SaaS applications, how CloudPloy fits into SaaS deployment workflows, and practical considerations for growing from your first 10 customers to your first 1,000.

Multi-Tenant Architecture Patterns for Laravel SaaS

The most fundamental SaaS architecture decision is how to handle tenant data isolation. Laravel supports all three common approaches:

Pattern How It Works Best For Complexity
Single database, shared tables All tenants' data in one database, filtered by a tenant_id column on every table Early-stage SaaS, <1,000 tenants, simple data models Low - standard Laravel queries with global scope
Single database, separate schemas Each tenant gets their own MySQL/PostgreSQL schema or table prefix (tenant_a_posts, tenant_b_posts) Medium-scale SaaS with compliance requirements Medium - dynamic connection management
Separate database per tenant Each customer has their own database, provisioned at onboarding Enterprise SaaS, GDPR data residency, large data volumes per customer High - database provisioning, migration management

CloudPloy supports all three patterns. Each application container connects to your database(s) via environment variables - you provision and manage multi-tenant databases however your application requires.

Shared Database with Tenant Scope (Most Common)

The simplest approach: add tenant_id to every table and use a global Eloquent scope to automatically filter queries. The popular stancl/tenancy package handles this for Laravel:

composer require stancl/tenancy
// config/tenancy.php
'tenant_model' => \App\Models\Tenant::class,
'id_generator' => \Stancl\Tenancy\UUIDGenerator::class,

// Central domain (your marketing site)
'central_domains' => [
    'yoursaas.com',
    'www.yoursaas.com',
],
// Tenant model
class Tenant extends Model implements TenantWithDatabase
{
    use HasDatabase, HasDomains;
}

// In a controller: switch to tenant context
tenancy()->initialize($tenant);
// All subsequent DB queries are scoped to this tenant's database

Database-Per-Tenant with Dynamic Connections

For stronger isolation, provision a new database for each tenant at signup. Configure Laravel to switch database connections based on the incoming request's subdomain or custom domain:

// app/Http/Middleware/InitializeTenancy.php
public function handle(Request $request, Closure $next)
{
    $host = $request->getHost();
    $tenant = Tenant::where('domain', $host)
                    ->orWhere('subdomain', explode('.', $host)[0])
                    ->firstOrFail();

    // Set dynamic database connection for this request
    config([
        'database.connections.tenant.database' => $tenant->database_name,
        'database.connections.tenant.username' => $tenant->database_user,
        'database.connections.tenant.password' => decrypt($tenant->database_password),
    ]);

    DB::purge('tenant');
    DB::reconnect('tenant');

    app()->instance('tenant', $tenant);

    return $next($request);
}

Deployment Architecture for SaaS on CloudPloy

A typical Laravel SaaS deployment on CloudPloy uses a single application container serving all tenants. The deployment pipeline:

  1. Developer pushes to main branch on GitHub
  2. CloudPloy detects the push and starts a new container build from your Dockerfile
  3. New container runs php artisan migrate --force to apply database migrations across all tenant databases
  4. CloudPloy health-checks the new container, then switches traffic atomically (blue-green)
  5. Old container drains over 30 seconds
  6. Total downtime: zero

Environment Variables for SaaS Configuration

Set these in App > Settings > Environment Variables:

# Core Laravel config
APP_ENV=production
APP_KEY=base64:your-app-key
APP_URL=https://yoursaas.com
APP_DEBUG=false

# Database (central database for tenant registry)
DB_CONNECTION=mysql
DB_HOST=your-db-host
DB_DATABASE=saas_central
DB_USERNAME=saas_user
DB_PASSWORD=your-db-password

# Queue (for background jobs - tenant provisioning, email, etc.)
QUEUE_CONNECTION=redis
REDIS_HOST=your-redis-host
REDIS_PASSWORD=your-redis-password

# Cache
CACHE_DRIVER=redis
SESSION_DRIVER=redis

# Logging (use stderr so CloudPloy captures it)
LOG_CHANNEL=stderr
LOG_LEVEL=error

Deploy Commands for SaaS Applications

Configure in App > Settings > Deploy Commands. For a multi-tenant app, migrations must run on the central database and potentially all tenant databases:

# Run central migrations
php artisan migrate --force

# For stancl/tenancy: migrate all tenant databases
php artisan tenants:artisan "migrate --force"

# Clear caches
php artisan config:cache
php artisan route:cache
php artisan view:cache

Custom Domains for SaaS Customers

Enterprise SaaS customers often require their own domain (app.theircorp.com) instead of your default subdomain (theircorp.yoursaas.com). This requires two things:

Wildcard Subdomain Setup

For subdomain-based tenancy (customer.yoursaas.com), add a wildcard domain in CloudPloy:

  1. Go to App > Settings > Domains
  2. Add *.yoursaas.com
  3. CloudPloy provisions a wildcard SSL certificate covering all subdomains
  4. In your DNS: *.yoursaas.com A YOUR_SERVER_IP

Custom Domain Provisioning

For customer-owned custom domains:

  1. Customer creates a CNAME pointing app.theircorp.com to theircorp.yoursaas.com
  2. Your application registers the custom domain via CloudPloy's API
  3. CloudPloy auto-provisions a Let's Encrypt certificate for the custom domain

CloudPloy's API endpoint for domain management allows your application to programmatically add new custom domains when customers configure them - no manual intervention needed per customer.

Queue Workers for SaaS Background Jobs

SaaS applications typically need background processing for: tenant provisioning (creating databases, running migrations), sending emails, processing uploads, generating reports, and scheduled maintenance tasks.

Configure dedicated queue workers in App > Workers:

# General queue worker (handles most jobs)
php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600

# Tenant provisioning worker (separate queue to not block general jobs)
php artisan queue:work redis --queue=provisioning --sleep=3 --tries=5

# Laravel Horizon (replaces multiple queue:work commands)
php artisan horizon

For tenant provisioning that takes 10-30 seconds (creating a database, running migrations, sending welcome email), queue the work and respond to the signup request immediately. Use Laravel's ShouldBeUnique interface on the provisioning job to prevent duplicate provisioning if a webhook fires twice.

Scaling a PHP SaaS Application

Vertical Scaling (Single Server)

Most PHP SaaS applications scale vertically for a long time. A 16GB RAM server with properly configured Redis caching and PHP-FPM process management handles thousands of concurrent users. Upgrade your server size in the CloudPloy dashboard - the process takes under 2 minutes with no downtime.

Server Size PHP-FPM Workers Typical Tenant Count Monthly Cost Ballpark
4GB RAM, 2 vCPU ~20 workers Up to ~500 tenants (low usage) $20-40/month
8GB RAM, 4 vCPU ~50 workers 500-2,000 tenants $40-80/month
16GB RAM, 8 vCPU ~100 workers 2,000-10,000 tenants $80-160/month
32GB RAM, 16 vCPU ~200 workers 10,000+ tenants $160-320/month

These are rough estimates - actual capacity depends on what each PHP request does, how much work hits the database vs Redis cache, and the distribution of active vs inactive tenants.

Redis Caching Strategy for SaaS

Caching is critical for SaaS applications where many tenants may request similar data. Key cache prefix strategy to avoid cross-tenant data leaks:

// In AppServiceProvider::boot() or tenant middleware
Cache::setPrefix("tenant_{$tenant->id}:");

// Or use a dedicated Redis database per tenant (for strict isolation)
config(['cache.stores.redis.database' => $tenant->redis_database_index]);

Using a consistent prefix per tenant means cache data is automatically isolated. Configure this in your tenancy middleware, not globally.

Database Connection Pooling

PHP-FPM creates a new database connection per request. With 50 active tenants and 10 concurrent requests each, that's potentially 500 simultaneous MySQL connections. Use ProxySQL connection pooling or set your MySQL max_connections appropriately:

# Check current connection count in App > Terminal
mysql -h $DB_HOST -u $DB_USERNAME -p$DB_PASSWORD -e "SHOW STATUS LIKE 'Threads_connected';"

# MySQL config for SaaS workloads (set via DB config or contact support)
max_connections = 500
wait_timeout = 60       # Close idle connections faster
interactive_timeout = 60

Per-Tenant Staging Environments

SaaS products that make significant database schema changes need careful staging. Recommended setup:

  • Production app: yoursaas.com / *.yoursaas.com - connected to main branch
  • Staging app: staging.yoursaas.com / *.staging.yoursaas.com - connected to staging branch

The staging app uses a separate database with a copy of your production tenant registry (with anonymized data). When a new feature branch is ready, merge to staging - the staging environment auto-deploys and runs migrations against staging data. Test with representative tenants before merging to main.

Monitoring SaaS Applications

SaaS applications need application-level monitoring beyond basic uptime checks. Configure these in CloudPloy and your application:

Health Check Endpoint

// routes/web.php
Route::get('/health', function () {
    // Check database connectivity
    DB::select('SELECT 1');

    // Check Redis connectivity
    Cache::put('health_check', 1, 5);

    // Check queue processing (is the queue worker running?)
    $lastJob = DB::table('jobs')->orderBy('created_at', 'desc')->first();
    $queueLag = $lastJob
        ? now()->diffInMinutes($lastJob->created_at)
        : 0;

    return response()->json([
        'status' => 'ok',
        'queue_lag_minutes' => $queueLag,
        'timestamp' => now()->toIso8601String(),
    ]);
});

Application Metrics to Track

  • Tenant provisioning success/failure rate and duration
  • Failed background job count (check failed_jobs table daily)
  • Database slow query log (enable in App > Database > Slow Query Log)
  • Redis memory usage (high memory = object cache eviction, degraded performance)
  • Response time p95/p99 per endpoint (available in App > Metrics)

Common SaaS Deployment Challenges

Challenge Cause Solution
Migration takes too long during deploy Large tenant count, complex schema change Use zero-downtime migrations (add column, backfill, then drop old column in separate deploy)
Cache data leaks between tenants Missing tenant prefix in cache keys Set cache prefix in tenant middleware; audit all Cache::put() calls
Tenant provisioning fails silently Job fails after max retries; no alert Add failed() method to provisioning job; notify team via webhook or email
Slow response for large tenants Missing database indexes, N+1 queries Add tenant_id to all compound indexes; use Laravel Debugbar in staging
Custom domain SSL takes hours DNS not propagated when certificate requested Poll for DNS propagation before triggering CloudPloy domain add; retry with exponential backoff
Queue jobs pile up after deploy Deploy restarts workers mid-job Use --max-time=3600 flag; workers gracefully finish current job before stopping

WordPress SaaS (WordPress Multisite)

Building a SaaS product on WordPress typically means WordPress Multisite - one WordPress installation serving multiple customer sites. CloudPloy supports WordPress Multisite with wildcard subdomain configuration.

Key requirements for WordPress Multisite on CloudPloy:

  • Wildcard DNS: *.yoursaas.com A YOUR_SERVER_IP
  • Wildcard domain in App > Settings > Domains: *.yoursaas.com
  • CloudPloy provisions a wildcard SSL certificate automatically
  • wp-config.php constants: MULTISITE, SUBDOMAIN_INSTALL, DOMAIN_CURRENT_SITE
  • Nginx is configured automatically for subdomain multisite rewrites

For WordPress SaaS with custom domains per customer, use the Mercator or similar domain mapping plugin in addition to the standard Multisite setup.


Building a SaaS application and want to review your architecture before launch? Read the staging environment guide for pre-production testing, the Docker container guide for container configuration, or contact CloudPloy to discuss your SaaS deployment requirements.