Building SaaS with Laravel (Part 8): Deploy Production & Scaling

· 12 min read

This is the final part of the "Building SaaS with Laravel" series. In the previous parts, we built all the features. Now it's time to ship the product to production.

1. Production Architecture

                    ┌──────────────┐
                    │  CloudFlare   │
                    │   DNS + CDN   │
                    └──────┬───────┘
                           │
                    ┌──────┴───────┐
                    │    Nginx      │
                    │  (Reverse    │
                    │   Proxy)     │
                    └──────┬───────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
        ┌─────┴─────┐ ┌────┴────┐ ┌────┴────┐
        │  PHP-FPM   │ │ PHP-FPM │ │ PHP-FPM │
        │  Worker 1  │ │ Worker 2│ │ Worker 3│
        └─────┬─────┘ └────┬────┘ └────┬────┘
              │            │            │
              └────────────┼────────────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
        ┌─────┴─────┐ ┌────┴────┐ ┌────┴────┐
        │  MySQL     │ │  Redis  │ │  Queue  │
        │  Primary   │ │ Cluster │ │ Workers │
        └───────────┘ └─────────┘ └─────────┘

2. Docker Setup for Production

Dockerfile

# Dockerfile
FROM php:8.3-fpm-alpine AS base

# Install system dependencies
RUN apk add --no-cache \
    nginx \
    supervisor \
    curl \
    libpng-dev \
    libjpeg-turbo-dev \
    libwebp-dev \
    freetype-dev \
    icu-dev \
    libzip-dev \
    oniguruma-dev \
    linux-headers

# Install PHP extensions
RUN docker-php-ext-configure gd \
        --with-freetype \
        --with-jpeg \
        --with-webp \
    && docker-php-ext-install -j$(nproc) \
        pdo_mysql \
        mbstring \
        exif \
        pcntl \
        bcmath \
        gd \
        intl \
        zip \
        opcache

# Install Redis extension
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apk del .build-deps

# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

# ------- Build Stage -------
FROM base AS build

# Copy composer files first (cache layer)
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist

# Copy application
COPY . .

# Generate autoload & optimize
RUN composer dump-autoload --optimize --no-dev \
    && php artisan config:cache \
    && php artisan route:cache \
    && php artisan view:cache \
    && php artisan event:cache

# Build frontend assets
FROM node:20-alpine AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ------- Production Image -------
FROM base AS production

# Copy application from build stage
COPY --from=build /var/www/html /var/www/html
COPY --from=frontend /app/public/build /var/www/html/public/build

# PHP production config
COPY docker/php/php-production.ini /usr/local/etc/php/conf.d/99-production.ini
COPY docker/php/www.conf /usr/local/etc/php-fpm.d/www.conf

# Nginx config
COPY docker/nginx/default.conf /etc/nginx/http.d/default.conf

# Supervisor config
COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf

# Set permissions
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache \
    && chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache

EXPOSE 80

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

PHP Production Config

; docker/php/php-production.ini
[PHP]
display_errors = Off
display_startup_errors = Off
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
log_errors = On
error_log = /var/log/php-error.log

memory_limit = 256M
max_execution_time = 30
max_input_time = 60
post_max_size = 50M
upload_max_filesize = 50M

[opcache]
opcache.enable = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.jit = 1255
opcache.jit_buffer_size = 128M

[session]
session.cookie_secure = On
session.cookie_httponly = On
session.cookie_samesite = Lax

Supervisor Config

; docker/supervisor/supervisord.conf
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log

[program:nginx]
command=nginx -g 'daemon off;'
autostart=true
autorestart=true

[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true

[program:queue-default]
command=php /var/www/html/artisan queue:work redis --queue=default --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=2
process_name=%(program_name)s_%(process_num)02d
user=www-data

[program:queue-webhooks]
command=php /var/www/html/artisan queue:work redis --queue=webhooks --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=1
process_name=%(program_name)s_%(process_num)02d
user=www-data

[program:scheduler]
command=sh -c "while true; do php /var/www/html/artisan schedule:run --no-interaction; sleep 60; done"
autostart=true
autorestart=true
user=www-data

3. Nginx Config (Wildcard Subdomain)

# docker/nginx/default.conf
server {
    listen 80;
    server_name *.saas-app.com saas-app.com;

    root /var/www/html/public;
    index index.php;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Gzip
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
    gzip_min_length 1000;

    # Static files caching
    location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_buffering on;
        fastcgi_buffer_size 16k;
        fastcgi_buffers 16 16k;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

4. Docker Compose for Production

# docker-compose.prod.yml
services:
  app:
    build:
      context: .
      target: production
    restart: unless-stopped
    ports:
      - "80:80"
    environment:
      - APP_ENV=production
      - APP_DEBUG=false
    env_file:
      - .env.production
    depends_on:
      - mysql
      - redis
    volumes:
      - storage:/var/www/html/storage/app
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/up"]
      interval: 30s
      timeout: 10s
      retries: 3

  mysql:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
    command: >
      --default-authentication-plugin=caching_sha2_password
      --innodb-buffer-pool-size=1G
      --innodb-log-file-size=256M
      --max-connections=200
      --slow-query-log=1
      --long-query-time=2

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data

volumes:
  storage:
  mysql_data:
  redis_data:

5. CI/CD with GitHub Actions

# .github/workflows/deploy.yml
name: Deploy Production

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_DATABASE: testing
          MYSQL_ROOT_PASSWORD: secret
        ports: ['3306:3306']
        options: --health-cmd="mysqladmin ping" --health-interval=10s
      redis:
        image: redis:7
        ports: ['6379:6379']

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo_mysql, redis
          coverage: none

      - name: Install dependencies
        run: composer install --prefer-dist --no-interaction

      - name: Run tests
        run: php artisan test --parallel
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: secret
          REDIS_HOST: 127.0.0.1

      - name: Run PHPStan
        run: ./vendor/bin/phpstan analyse --memory-limit=512M

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Login to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          target: production
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/saas-app

            # Pull new image
            docker compose -f docker-compose.prod.yml pull app

            # Run migrations
            docker compose -f docker-compose.prod.yml run --rm app \
              php artisan migrate --force

            # Restart with zero downtime
            docker compose -f docker-compose.prod.yml up -d --no-deps app

            # Clear old images
            docker image prune -f

            # Verify health
            sleep 10
            curl -f http://localhost/up || exit 1

            echo "Deployment successful!"

6. SSL Wildcard Certificate

For subdomain routing, you need a wildcard SSL certificate:

With Certbot (Let's Encrypt)

# Install certbot with DNS plugin (e.g., Cloudflare)
sudo apt install certbot python3-certbot-dns-cloudflare

# Create Cloudflare API token file
sudo mkdir -p /etc/letsencrypt
cat > /etc/letsencrypt/cloudflare.ini << EOF
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
EOF
sudo chmod 600 /etc/letsencrypt/cloudflare.ini

# Generate wildcard certificate
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "saas-app.com" \
  -d "*.saas-app.com"

Nginx SSL Config

# /etc/nginx/conf.d/saas-app.conf (host-level, before Docker)
server {
    listen 80;
    server_name saas-app.com *.saas-app.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name saas-app.com *.saas-app.com;

    ssl_certificate /etc/letsencrypt/live/saas-app.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/saas-app.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        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;
    }
}

7. Environment Config for Production

# .env.production
APP_NAME="SaaS App"
APP_ENV=production
APP_KEY=base64:your-generated-key
APP_DEBUG=false
APP_URL=https://saas-app.com
TENANT_DOMAIN=saas-app.com

LOG_CHANNEL=stack
LOG_LEVEL=warning

DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=saas_app
DB_USERNAME=saas_app
DB_PASSWORD=your-secure-password

CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis

REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=your-redis-password

# Stripe Production Keys
STRIPE_KEY=pk_live_xxxx
STRIPE_SECRET=sk_live_xxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxx

MAIL_MAILER=smtp
MAIL_HOST=smtp.postmarkapp.com
MAIL_PORT=587
MAIL_USERNAME=your-postmark-token
MAIL_PASSWORD=your-postmark-token
MAIL_FROM_ADDRESS=noreply@saas-app.com
MAIL_FROM_NAME="SaaS App"

# Admin
ADMIN_EMAILS=you@example.com

# Security
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
BCRYPT_ROUNDS=12

8. Monitoring & Alerting

Laravel Health Check

// routes/web.php
Route::get('/up', function () {
    return response()->json([
        'status' => 'ok',
        'timestamp' => now()->toIso8601String(),
    ]);
})->name('health');

Logging Strategy

// config/logging.php
'channels' => [
    'stack' => [
        'driver' => 'stack',
        'channels' => ['daily', 'stderr'],
    ],

    'daily' => [
        'driver' => 'daily',
        'path' => storage_path('logs/laravel.log'),
        'level' => 'warning',
        'days' => 14,
    ],

    'billing' => [
        'driver' => 'daily',
        'path' => storage_path('logs/billing.log'),
        'level' => 'info',
        'days' => 90,
    ],

    'security' => [
        'driver' => 'daily',
        'path' => storage_path('logs/security.log'),
        'level' => 'info',
        'days' => 90,
    ],
],

Usage:

// Billing events
Log::channel('billing')->info('Subscription created', [
    'tenant_id' => $tenant->id,
    'plan' => $plan->slug,
]);

// Security events
Log::channel('security')->warning('Failed login attempt', [
    'email' => $email,
    'ip' => request()->ip(),
]);

9. Backup Strategy

// Using spatie/laravel-backup
// config/backup.php
'backup' => [
    'source' => [
        'databases' => ['mysql'],
    ],
    'destination' => [
        'disks' => ['s3'],
    ],
],

'cleanup' => [
    'strategy' => \Spatie\Backup\Tasks\Cleanup\Strategies\DefaultStrategy::class,
    'default_strategy' => [
        'keep_all_backups_for_days' => 7,
        'keep_daily_backups_for_days' => 30,
        'keep_weekly_backups_for_weeks' => 8,
        'keep_monthly_backups_for_months' => 4,
    ],
],

Schedule:

// routes/console.php
Schedule::command('backup:run --only-db')->dailyAt('02:00');
Schedule::command('backup:clean')->dailyAt('03:00');
Schedule::command('backup:monitor')->dailyAt('04:00');

10. Scaling Checklist

Database Scaling

// config/database.php - Read/Write splitting
'mysql' => [
    'read' => [
        'host' => [env('DB_READ_HOST', '127.0.0.1')],
    ],
    'write' => [
        'host' => [env('DB_HOST', '127.0.0.1')],
    ],
    'sticky' => true, // Prevents read-after-write inconsistency
    'driver' => 'mysql',
    'database' => env('DB_DATABASE'),
    'username' => env('DB_USERNAME'),
    'password' => env('DB_PASSWORD'),
],

Queue Scaling

# Separate queues by priority
php artisan queue:work redis --queue=high,default,low
php artisan queue:work redis --queue=webhooks
php artisan queue:work redis --queue=emails

Redis Scaling

// config/database.php
'redis' => [
    'cache' => [
        'url' => env('REDIS_CACHE_URL'),
        'host' => env('REDIS_CACHE_HOST', '127.0.0.1'),
        'database' => '1',
    ],
    'session' => [
        'url' => env('REDIS_SESSION_URL'),
        'host' => env('REDIS_SESSION_HOST', '127.0.0.1'),
        'database' => '2',
    ],
    'queue' => [
        'url' => env('REDIS_QUEUE_URL'),
        'host' => env('REDIS_QUEUE_HOST', '127.0.0.1'),
        'database' => '3',
    ],
],

11. Performance Optimization Checklist

// app/Console/Commands/OptimizeProduction.php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Console\Command;

class OptimizeProduction extends Command
{
    protected $signature = 'app:optimize-production';
    protected $description = 'Run all production optimizations';

    public function handle(): int
    {
        $this->info('Optimizing for production...');

        $commands = [
            'config:cache'  => 'Caching config...',
            'route:cache'   => 'Caching routes...',
            'view:cache'    => 'Caching views...',
            'event:cache'   => 'Caching events...',
            'icons:cache'   => 'Caching icons...',
        ];

        foreach ($commands as $command => $message) {
            $this->info($message);
            try {
                $this->call($command);
            } catch (\Exception $e) {
                $this->warn("Skipped: {$command}");
            }
        }

        $this->info('Production optimization complete!');

        return self::SUCCESS;
    }
}

12. Launch Checklist

Before launching, verify each item:

Security

  • APP_DEBUG = false
  • APP_ENV = production
  • HTTPS enforced
  • CSRF protection active
  • SQL injection protection (Eloquent/parameterized)
  • XSS protection (Blade escaping)
  • Security headers configured
  • Rate limiting active
  • Admin routes protected
  • Stripe webhook signature verified

Performance

  • OPcache enabled
  • Config/route/view cached
  • Database indexes verified
  • Redis for cache/session/queue
  • CDN for static assets
  • Gzip enabled
  • Image optimization

Monitoring

  • Health check endpoint
  • Error logging configured
  • Uptime monitoring (UptimeRobot/Pingdom)
  • Database backup automated
  • Billing webhook logs reviewed
  • Daily metrics email configured

Business

  • Stripe production keys
  • Domain DNS configured
  • Wildcard SSL active
  • Email sending tested
  • Trial flow tested end-to-end
  • Payment flow tested
  • Terms of Service & Privacy Policy

Full Series Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                        FRONTEND                              │
│   Landing Page │ App (Blade + Alpine.js) │ Admin Dashboard   │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────┴─────────────────────────────────┐
│                     ROUTING LAYER                            │
│   Main Domain Routes │ Subdomain Routes │ API Routes (v1)   │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────┴─────────────────────────────────┐
│                   MIDDLEWARE STACK                            │
│   Auth │ Tenant Resolution │ Feature Limits │ Rate Limiting  │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────┴─────────────────────────────────┐
│                   APPLICATION LAYER                           │
│   Controllers │ Actions │ Data Objects │ Resources           │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────┴─────────────────────────────────┐
│                    DOMAIN LAYER                               │
│   Tenant │ User │ Team │ Project │ Billing │ API             │
│   Models │ Scopes │ Events │ Policies │ Services             │
└───────────────────────────┬─────────────────────────────────┘
                            │
┌───────────────────────────┴─────────────────────────────────┐
│                 INFRASTRUCTURE                                │
│   MySQL │ Redis │ Queue │ Stripe │ Mail │ Storage            │
└─────────────────────────────────────────────────────────────┘

Series Summary

Across 8 parts, we built a complete SaaS application:

Part Content Key Concepts
1 Setup & architecture Domain structure, migrations, models
2 Multi-tenancy Global scopes, data isolation, 4-layer protection
3 Billing Stripe Cashier, subscriptions, webhooks, trials
4 Team management Invitations, roles, permissions (Spatie)
5 Usage tracking Feature limits, usage bars, upgrade prompts
6 Admin dashboard Metrics, MRR, churn, health checks
7 API & Webhooks REST API, Sanctum, webhook delivery
8 Production Docker, CI/CD, SSL, scaling, monitoring

Lessons Learned

  1. Multi-tenancy requires defense in depth — never rely on a single layer of protection
  2. Billing is core — invest time in webhook handling and edge cases
  3. Usage limits create upgrade incentives — design limits thoughtfully
  4. Monitor everything — MRR, churn, and conversion are vital metrics
  5. API is a premium feature — creates value for higher-tier plans
  6. Automate deployments — manual deployment will slow you down at scale

Next Steps

If you want to extend the application further:

  • Onboarding wizard with interactive tutorials
  • In-app notifications with Laravel Echo + Reverb
  • Custom domains for tenants (premium feature)
  • Data export (GDPR compliance)
  • Audit logging for compliance
  • A/B testing pricing pages

Thank you for following this series. Happy building!

Comments