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
- Multi-tenancy requires defense in depth — never rely on a single layer of protection
- Billing is core — invest time in webhook handling and edge cases
- Usage limits create upgrade incentives — design limits thoughtfully
- Monitor everything — MRR, churn, and conversion are vital metrics
- API is a premium feature — creates value for higher-tier plans
- 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!